Skip to content

Fix some minor bugs#33

Merged
AlanRockefeller merged 6 commits intomainfrom
test
Jan 27, 2026
Merged

Fix some minor bugs#33
AlanRockefeller merged 6 commits intomainfrom
test

Conversation

@AlanRockefeller
Copy link
Copy Markdown
Owner

@AlanRockefeller AlanRockefeller commented Jan 26, 2026

Fix some minor bugs

Summary by CodeRabbit

  • New Features

    • Real-time highlight indicators (headroom & clipping), EXIF-aware orientation, optional OpenCV detection, and generation-aware preview selection.
  • Bug Fixes

    • More robust error handling around image save/edits to prevent crashes; safer save/export EXIF handling.
  • Improvements

    • Smoother preview gating (reduced stale frames), tighter UI/preview/histogram synchronization, improved histogram for narrow displays and slider reset behavior.
  • Documentation

    • Changelog updated and project version bumped.
  • Tests

    • Expanded test coverage for EXIF, highlights, preview logic, error handling.
  • Chores

    • Added local ignore entry and local settings file.

✏️ Tip: You can customize this high-level summary in your review settings.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Jan 26, 2026

Walkthrough

Adds EXIF orientation handling/sanitization across decode/load/save, optional OpenCV detection, analysis-driven highlight/shadow recovery with caching, a gate-style preview refresh pipeline, histogram/preview UI sync updates, robust save error handling, many tests/tools, and bumps version to 1.5.2.

Changes

Cohort / File(s) Summary
Release & Metadata
​.gitignore, ChangeLog.md, pyproject.toml
Bumped version to 1.5.2, added release notes, and ignored Logs/error_log.txt.
Config & Utility scripts
faststack/config.py, faststack/check_scipy.py, faststack/tests/test_version_sort.py
Replaced natural sort with Windows-path-aware version_sort_key; added SciPy availability checker and corresponding unit test.
Optional deps centralization
faststack/imaging/optional_deps.py
New module exposing HAS_OPENCV and optional cv2 to avoid circular imports.
EXIF / Orientation utilities
faststack/imaging/orientation.py, faststack/imaging/prefetch.py
New orientation helpers (get_exif_orientation, apply_orientation_to_np, apply_exif_orientation) and post-decode EXIF orientation application in prefetch.
Editor: processing, caching, EXIF sanitization
faststack/imaging/editor.py, faststack/imaging/math_utils.py
Large refactor: sanitize_exif_orientation, orientation-aware load/save, preview caching (get_preview_data_cached), highlight/shadow analysis & recovery math, detail-band caching, upstream-edit hashing, and more deterministic preview/histogram update flows.
Preview / prefetch pipeline
faststack/imaging/prefetch.py
Added EXIF capture and centralized post-decode orientation step; timing/log markers updated.
App orchestration & preview gating
faststack/app.py, faststack/ui/provider.py
Replaced timer-based preview coalescing with inflight/queued gating, added highlightState and highlightStateChanged, generation-aware preview selection, increased keepalive deque, and OpenCV availability exposure.
QML UI updates
faststack/qml/ImageEditorDialog.qml, faststack/qml/SingleChannelHistogram.qml
Slider reset/debounce and model swap, histogram height/labels change, added headroom/clipped indicators, and narrow-canvas histogram downsampling.
Tests — EXIF, highlights, integration & misc
faststack/tests/*, tests/* (many files)
Extensive new/expanded tests covering EXIF sanitization/orientation, highlight recovery/semantics, cache invalidation, generation-aware preview logic, error handling, OpenCV fallbacks, and related unit/integration cases.
Ad-hoc/manual scripts
faststack/test_pil_blur.py, faststack/tests/mini_test.py, faststack/tests/manual_test_error_handling.py
Added small/manual test scripts for PIL blur and basic error-handling scenarios.

Sequence Diagram(s)

sequenceDiagram
    participant QML as QML / User
    participant Provider as ImageProvider / UIState
    participant App as AppController
    participant Editor as ImageEditor
    participant Prefetch as Prefetcher
    participant EXIF as EXIF/Orientation

    QML->>Provider: request preview (id[/generation])
    Provider->>App: forward request / select source
    App->>Editor: get_preview_data_cached()
    Editor->>Prefetch: decode image (if needed)
    Prefetch->>EXIF: read exif_obj
    Prefetch-->>Editor: decoded buffer
    Editor->>EXIF: apply_exif_orientation(buffer, exif_obj)
    EXIF-->>Editor: oriented buffer
    Editor->>Editor: _analyze_highlight_state()
    Editor-->>Provider: preview + highlightState
    Provider->>QML: emit highlightStateChanged
Loading
sequenceDiagram
    participant QML as QML / User
    participant App as AppController
    participant Editor as ImageEditor
    participant Provider as ImageProvider
    participant Cache as Preview Cache

    QML->>App: rotate_image_cw()
    App->>Editor: rotate_image_cw()
    Editor->>Cache: invalidate/refresh preview (_edits_rev)
    Editor->>Editor: _apply_edits() → _apply_highlights_shadows()
    Editor-->>Provider: new preview + highlightState
    Provider->>QML: emit highlightStateChanged
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

  • Release version 1.5.1 #32: Continues/refines EXIF/orientation, preview caching, and highlight logic introduced earlier — strong overlap in editor/prefetch changes.
  • Update image editor #26: Overlaps on preview/histogram async pipeline and ImageEditor preview caching/gating.
  • Fix some bugs #25: Related edits to histogram, crop/rotate flows and UI/provider coordination.
🚥 Pre-merge checks | ✅ 1 | ❌ 2
❌ Failed checks (1 warning, 1 inconclusive)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 76.58% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'Fix some minor bugs' is vague and generic. It does not convey specific information about the substantial changes made, including EXIF orientation handling, highlight recovery, preview refresh improvements, and numerous other features. Replace with a more descriptive title that captures the primary focus, such as 'Add EXIF orientation handling, highlight recovery, and preview caching improvements' or focus on the most significant change if space is limited.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
  • 📝 Generate docstrings

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 19

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
pyproject.toml (1)

29-29: Move OpenCV to optional dependencies to match architectural support.

The codebase has been refactored to support optional OpenCV (as stated in ChangeLog.md) with faststack.imaging.optional_deps providing graceful fallback when OpenCV is unavailable. However, opencv-python is still listed as a required dependency in pyproject.toml. Move it to [project.optional-dependencies] to align the dependency configuration with the implemented architecture.

🤖 Fix all issues with AI agents
In `@ChangeLog.md`:
- Line 8: Update the inconsistent heading levels under the 1.5.2 release section
in ChangeLog.md: change the subheadings currently using '####' (e.g., the '####
Added', '#### Changed', and '#### Fixed' headings) to '###' so they match the
other release sections' h3 hierarchy and maintain a correct h2 → h3 → h4
structure.

In `@faststack/app.py`:
- Around line 771-803: Remove unused variables and the stray f-string: drop the
unused backup_path from the unpacking of result and remove the found_index local
(it’s assigned but never used) in the block around saved_path handling; update
the unpack to only capture saved_path and adjust any subsequent references to
found_index (none expected) to use new_index when needed. Also locate and remove
the stray f-string with no placeholders near the same area (around line ~816) so
there are no empty f-strings left in the function; keep all path-resolution
logic using saved_path, new_index, self.image_files, and img.path.resolve
unchanged.

In `@faststack/imaging/math_utils.py`:
- Around line 128-131: The elif branch is dead because max_rgb is already
defined when pre_exposure_linear is None; remove the redundant elif and simply
set max_rgb only when pre_exposure_linear is not None (i.e., keep the if
pre_exposure_linear is not None: max_rgb = rgb_linear.max(axis=2) and delete the
elif 'max_rgb' not in locals() branch), ensuring the code relies on the earlier
definition of max_rgb when pre_exposure_linear is None; reference variables:
pre_exposure_linear, max_rgb, rgb_linear.
- Around line 156-164: Remove the unused parameter k from the
_highlight_recover_linear signature and its docstring reference, and update all
callers to stop passing k; specifically, edit the function
_highlight_recover_linear to delete the k: float = 6.0 parameter and any
docstring text like "Shoulder curve steepness", then modify every call site that
currently passes k=... (e.g., calls from editor.py and tests in
test_highlight_recovery.py) to omit that argument so behavior remains unchanged.

In `@faststack/imaging/orientation.py`:
- Around line 35-76: The function apply_orientation_to_np returns early for
orientation <= 1 and for the else branch without enforcing C-contiguity contrary
to its docstring; update those return paths to wrap the returned array with
np.ascontiguousarray(...) so the function always returns a C-contiguous numpy
array (apply to the early return for orientation <= 1 and the final else branch,
leaving the post-transformation contiguity check for transformed results
intact).

In `@faststack/imaging/prefetch.py`:
- Around line 568-592: Remove the unused timer variable t_after_orient and
replace the broad "except Exception as e" with a narrow set of expected errors
around applying EXIF and opening the image (e.g., OSError,
PILImage.UnidentifiedImageError, AttributeError) so programmer errors aren't
swallowed; keep the try/except only covering the calls to apply_exif_orientation
and PILImage.open/getexif (references: apply_exif_orientation, exif_obj,
PILImage.open, img.getexif), and ensure the subsequent re-establishment of h, w,
buffer, bytes_per_line, and mv always runs regardless of whether EXIF
application succeeded.

In `@faststack/qml/ImageEditorDialog.qml`:
- Line 150: The Detail and Effects sections are using the wrong models:
currently the Repeater in the Detail header uses effectsModel and the Effects
header uses detailModel; fix by swapping the model bindings so the Detail
section's Repeater uses detailModel and the Effects section's Repeater uses
effectsModel (both delegates remain editSlider). Locate the Repeater instances
that reference effectsModel/detailModel (one at the shown diff and the other
around the second occurrence mentioned) and update their model properties
accordingly or rename headers if the intent was reversed.

In `@faststack/tests/reproduce_exif_bug.py`:
- Around line 5-8: Replace the hardcoded Windows path and global cv2 mock:
compute and insert a project-root path relative to __file__ instead of
r"c:\code\faststack" and remove the module-level sys.modules['cv2'] =
MagicMock() assignment; instead, in the test class implement setUp and tearDown
(or use a context manager) to call patch.dict(sys.modules, {'cv2': MagicMock()})
so the mock is scoped to each test, and restore sys.path changes there as well;
look for the module-level sys.path.insert and sys.modules['cv2'] usages and
update them to use __file__, setUp/tearDown, patch.dict, and MagicMock.

In `@faststack/tests/test_editor_error_handling.py`:
- Around line 16-34: The tests assume ImageEditor.load_image raises OSError and
save_image propagates PermissionError, but the implementation of
ImageEditor.load_image returns False on failure and ImageEditor.save_image wraps
exceptions in RuntimeError; update the tests to match this contract: change the
load-image test to assert that editor.load_image("non_existent_file.jpg")
returns False (and not raise), and update the save-image test to expect a
RuntimeError (containing the underlying permission message if needed) instead of
PermissionError; refer to ImageEditor.load_image and ImageEditor.save_image when
locating the behavior to align tests with the implementation.

In `@faststack/tests/test_exif_compat.py`:
- Around line 124-127: The test currently patches self.editor._write_tiff_16bit
as mock_tiff but never uses it, causing unused-mock lint noise; remove the
unused patch binding by deleting "patch.object(self.editor, '_write_tiff_16bit')
as mock_tiff" from the with-statement so only needed mocks
(sanitize_exif_orientation, create_backup_file, PIL.Image.fromarray) remain.
- Around line 38-47: The test test_missing_load_method is not actually removing
load() because MagicMock recreates attributes; change the mocked Exif to forbid
a load attribute (e.g., create the mock with spec_set=[] or a simple object
without load) so hasattr(mock_exif_instance, "load") returns False and the code
path in _get_sanitized_exif_bytes falls back to editor.original_image.getexif;
update the patch that returns mock_exif_instance (patch 'PIL.Image.Exif') and
ensure original_image.getexif is a MagicMock to assert it was called.

In `@faststack/tests/test_generation_aware_preview.py`:
- Line 52: Remove the unused variable assignment "img =
self.provider.requestImage(\"0/5\", None, None)" in the test (or replace with a
throwaway `_ =` if you prefer clarity) so the call remains but the unused value
does not trigger lint; locate the call in test_generation_aware_preview.py where
self.provider.requestImage("0/5", None, None) is invoked and delete the
left-hand assignment.

In `@faststack/tests/test_highlight_recovery.py`:
- Around line 197-212: The main test runner omits calling
test_source_clipping_detection() and wraps all tests in a broad try/except which
suppresses exceptions; update the main-guard to include a call to
test_source_clipping_detection() alongside the other tests (e.g., after
test_headroom_shoulder()) and remove the outer try/except so failures propagate
naturally (do not catch Exception around the test invocations).

In `@faststack/tests/test_highlight_state_normalization.py`:
- Around line 14-33: Rename the duplicate test function so both scenarios run
(e.g., keep one as test_highlight_state_normalization_standard and rename the
other to test_highlight_state_normalization_legacy_keys), ensure the setup uses
self.mock_editor._last_highlight_state with legacy keys (headroom_pct,
clipped_pct, near_white_pct) in one test and canonical keys (headroom_pct,
source_clipped_pct, current_nearwhite_pct) in the other, and add assertions that
the provider/normalizer returns expected values for canonical keys when fed
legacy keys (e.g., source_clipped_pct and current_nearwhite_pct resolve to 0.0
or the expected mapped values) so both behaviors are covered; update any test
docstrings accordingly and remove the stub duplicate.

In `@faststack/tests/test_highlights_responsiveness.py`:
- Around line 34-44: The test currently only prints values and has no
assertions; update test_highlights_responsiveness.py to assert the expected
behavior using the existing variables (steps, vals, out and computed diff = inp
- outp): add assertions that for indices where vals[i] < 0.75 (the pivot
referenced in comments) diff == 0 (or nearly zero with a small tolerance like
1e-6) and for a set of lower brightness inputs verify diff > 0.01 to ensure
responsiveness; keep the loop that computes inp/outp/diff but replace/augment
prints with assert statements that use exact or tolerance comparisons so the
test will fail on regressions.

In `@faststack/tests/test_highlights_v2.py`:
- Around line 39-65: The test_analysis_decoupling test contains an unused local
variable srgb which ruff flags; remove the unused srgb declaration from the test
or, if it was intended to feed the analysis path, pass it into the call that
triggers analysis (e.g., provide srgb to editor._apply_edits or to whatever
ImageEditor method that consumes an sRGB input) so that the variable is actually
used; update assertions only if the analysis input changes; relevant symbols:
test_analysis_decoupling, srgb, editor._apply_edits, ImageEditor,
_initial_edits, and _last_highlight_state.

In `@faststack/tests/test_prefetch_logic.py`:
- Around line 1-7: The test imports use Future in a type/spec at spec=Future but
never imports it, causing a NameError at runtime; fix by adding an import for
concurrent.futures.Future (or from concurrent.futures import Future) at the top
of faststack/tests/test_prefetch_logic.py so the spec=Future reference in the
test (around the Prefetcher-related mocks) resolves correctly.

In `@faststack/ui/provider.py`:
- Around line 823-829: Update the docstring for the highlight analysis return
value in provider.py to match the actual keys returned by the function: replace
references to clipped_pct and near_white_pct with source_clipped_pct and
current_nearwhite_pct (keep headroom_pct as-is), and adjust the descriptions to
refer to the SOURCE image for source_clipped_pct and the processed/current state
for current_nearwhite_pct so the docstring matches the returned dict keys from
this highlight analysis routine.

In `@tests/test_highlights_v2.py`:
- Line 3: The test imports _apply_headroom_shoulder from the wrong module;
update the import in tests/test_highlights_v2.py to import
_apply_headroom_shoulder from its defining module faststack.imaging.math_utils
(keep ImageEditor import as-is if needed) so the test references the symbol
_apply_headroom_shoulder from the correct source module for maintainability and
consistency with test_rolloff.py.
🧹 Nitpick comments (23)
faststack/check_scipy.py (1)

1-6: Consider relocating this utility script.

This appears to be a developer utility for checking SciPy availability. Placing it directly in faststack/ means it could be inadvertently imported as part of the package. Consider:

  1. Moving to a scripts/ or tools/ directory outside the main package
  2. Adding a leading underscore (_check_scipy.py) to indicate internal use
  3. Excluding it from package distribution in pyproject.toml
faststack/test_pil_blur.py (1)

1-28: Test file placed outside test directory.

This test file is located in faststack/ rather than faststack/tests/. This means:

  1. It won't be discovered by pytest (per testpaths = ["faststack/tests"] in pyproject.toml)
  2. It will be included in the distributed package unnecessarily

Consider moving to faststack/tests/test_pil_blur.py and adapting to use pytest/unittest conventions.

faststack/tests/manual_test_error_handling.py (1)

83-89: Minor style: split multi-statement lines.

Lines 84 and 86 have multiple statements on one line, which reduces readability.

Suggested fix
     success = True
-    if not test_load_image_raises(): success = False
+    if not test_load_image_raises():
+        success = False
     print("-" * 20)
-    if not test_save_image_raises(): success = False
+    if not test_save_image_raises():
+        success = False
faststack/imaging/prefetch.py (1)

467-469: Remove the leftover pass placeholder.
Orientation is handled later, so this block can be dropped to avoid confusion.

🧹 Proposed cleanup
-                        # EXIF orientation correction
-
-                        pass
faststack/tests/test_editor.py (1)

60-67: Consider using centralized HAS_OPENCV for consistency.

The OpenCV guard works correctly, but this PR introduces faststack/imaging/optional_deps.py with a centralized HAS_OPENCV flag. For consistency across the codebase, consider using:

from faststack.imaging.optional_deps import HAS_OPENCV

if not HAS_OPENCV:
    self.skipTest("OpenCV not installed, skipping texture test")

That said, the local import approach is also valid for test isolation purposes.

faststack/config.py (1)

39-43: Consider extracting version_sort_key to avoid duplication.

The same function is duplicated in faststack/tests/test_version_sort.py (lines 6-10). If the test is meant to verify this exact logic, consider importing from a shared location or having the test import from faststack.config.

faststack/tests/test_highlights_responsiveness.py (1)

12-12: Prefer relative import or proper package installation over sys.path manipulation.

Using sys.path.append is fragile. Consider running tests with python -m pytest from the repo root or ensuring the package is installed in development mode (pip install -e .).

faststack/tests/test_rolloff.py (1)

1-5: Remove unused imports.

MagicMock, patch, and sys are imported but never used in this test file. The comments on lines 6-7 mention mocking but no mocking is actually performed since _apply_headroom_shoulder is a pure math function.

🧹 Proposed cleanup
 import unittest
-from unittest.mock import MagicMock, patch
 import numpy as np
-import sys
-
-# We check for modules that might be missing and mock them if needed
-# inside the test setup to avoid import errors at module level.
faststack/tests/test_sensitivity.py (3)

5-6: Consider using a proper test discovery mechanism instead of sys.path manipulation.

Direct sys.path manipulation can cause import issues and is fragile. Consider running tests via python -m pytest or python -m unittest from the project root, which handles path resolution automatically.


10-45: Consider converting to unittest.TestCase for consistency.

This test uses print statements and bare assert for validation, which differs from other test files in this PR that use the unittest framework. Using unittest.TestCase provides better test discovery, structured assertions, and consistent output formatting.

♻️ Example conversion
import unittest
import numpy as np
from faststack.imaging.editor import ImageEditor

class TestContrastSaturationSensitivity(unittest.TestCase):
    def test_contrast_at_1_0(self):
        editor = ImageEditor()
        arr = np.zeros((100, 100, 3), dtype=np.float32)
        arr[:, :50, 0] = 0.8
        arr[:, 50:, 1] = 0.8
        editor.float_preview = arr
        
        edits = editor._initial_edits()
        edits['contrast'] = 1.0
        out = editor._apply_edits(arr.copy(), edits=edits)
        
        self.assertAlmostEqual(out[0, 0, 0], 0.92, delta=0.01)

    def test_saturation_at_1_0(self):
        editor = ImageEditor()
        arr = np.zeros((100, 100, 3), dtype=np.float32)
        arr[:, :50, 0] = 0.8
        editor.float_preview = arr
        
        edits = editor._initial_edits()
        edits['saturation'] = 1.0
        out = editor._apply_edits(arr.copy(), edits=edits)
        
        self.assertAlmostEqual(out[0, 0, 0], 1.0804, delta=0.01)

if __name__ == "__main__":
    unittest.main()

47-52: Catching bare Exception hides unexpected failures.

Per the static analysis hint, catching Exception can mask programming errors. Since this is test scaffolding, a bare exception may be acceptable for quick diagnostics, but it makes debugging harder if an unexpected error occurs.

🧹 Narrow the exception type or log the traceback
 if __name__ == "__main__":
     try:
         test_contrast_saturation_sensitivity()
-    except Exception as e:
-        print(f"Test failed: {e}")
+    except AssertionError as e:
+        print(f"Test assertion failed: {e}")
+        sys.exit(1)
+    except ImportError as e:
+        print(f"Import failed: {e}")
         sys.exit(1)
faststack/tests/test_raw_pipeline.py (1)

88-97: String-based method name assertion is fragile.

Line 95 uses '_on_develop_finished' in str(callback.func) to verify the callback. This could break if the method is renamed or if the string representation changes. Consider using a more robust check.

♻️ More robust callback verification
         # callback is functools.partial(self._on_develop_finished, False, err_msg)
         # For a bound method, callback.func is the method
-        self.assertTrue(hasattr(callback, 'func'))
-        self.assertTrue('_on_develop_finished' in str(callback.func))
+        self.assertTrue(hasattr(callback, 'func'))
+        self.assertEqual(callback.func.__name__, '_on_develop_finished')
         self.assertEqual(callback.args[0], False)  # Success = False
         self.assertIn("timed out", callback.args[1])  # Msg
faststack/tests/mini_test.py (1)

1-47: Consider converting to unittest.TestCase for consistency with other test files.

This diagnostic script uses print-based PASS/FAIL output and bare exception handlers. While functional for quick validation, converting to unittest.TestCase would:

  • Enable test discovery via standard runners
  • Provide structured assertion methods
  • Align with other test files in this PR

The bare Exception catches (lines 19, 39, 45) flagged by static analysis are acceptable here since they serve diagnostic purposes in a test script.

faststack/tests/test_cache_invalidation.py (3)

18-26: Use a temporary directory instead of a relative path.

The hardcoded path tests/dummy_images_cache assumes the test runs from the repository root. This can fail in different test runners or CI environments. Use tempfile.mkdtemp() for reliable cleanup.

♻️ Use tempfile for test isolation
+import tempfile
+
 def test_cache_stability():
     """Verify that cache hash remains stable when reloading the same unmodified file."""
     
-    # Setup dummy image
-    test_dir = Path("tests/dummy_images_cache")
-    test_dir.mkdir(parents=True, exist_ok=True)
+    # Setup dummy image in temp directory
+    test_dir = Path(tempfile.mkdtemp(prefix="faststack_cache_test_"))
     
     img_path = test_dir / "test_cache.jpg"

49-66: Use assertions instead of print-based PASS/FAIL.

The test prints PASS/FAIL but doesn't raise exceptions on failure, so test runners won't detect failures. Lines 49-52 and 63-66 should use assert or unittest assertions.

♻️ Add assertions for test runner compatibility
     if hash1 == hash2:
         print("PASS: Hash is stable across reloads.")
     else:
-        print("FAIL: Hash changed across reloads (unnecessary invalidation).")
+        # Note: Current known behavior - hash changes due to id() usage
+        print("INFO: Hash changed across reloads (known behavior with id()-based hashing).")
 
     # ...
 
     if hash3 != hash2:
         print("PASS: Hash changed after mtime update.")
     else:
-        print("FAIL: Hash did NOT change after mtime update.")
+        raise AssertionError("FAIL: Hash did NOT change after mtime update.")

68-72: Avoid bare except clause.

Per static analysis, the bare except: at line 71 can hide errors. Use a specific exception type or at minimum log the error.

🧹 Proposed fix
     # Cleanup
     try:
         shutil.rmtree(test_dir)
-    except:
-        pass
+    except OSError as e:
+        print(f"Warning: Cleanup failed: {e}")
faststack/tests/test_fallback_blur.py (1)

5-5: Remove unused import.

PIL.Image is imported but never used in this test file.

🧹 Remove unused import
 import unittest
 import numpy as np
 from unittest.mock import patch, MagicMock
-from PIL import Image
faststack/imaging/editor.py (6)

145-174: Consider using underscore prefix for unused variables.

At line 146, h and w are unpacked but never used. Per static analysis hint and Python conventions, prefix with underscore:

Suggested fix
-            h, w, c = arr.shape
+            _h, _w, c = arr.shape

590-594: Unused variables nw and nh.

These variables are unpacked but never used since np.stack infers the shape automatically.

Suggested fix
     # Merge back
     # Assume all channels rotated to same size
-    nw, nh = channels[0].size
     new_arr = np.stack([np.array(ch) for ch in channels], axis=-1)
     return new_arr

609-612: Remove unused variable is_export.

is_export is assigned but never used; the code uses for_export directly throughout.

Suggested fix
-        is_export = for_export
-        # Alias
         arr = img_arr

1092-1095: Consider defining a custom exception or shortening the message.

Per static analysis (TRY003), long exception messages are better placed in a custom exception class. However, this is a minor style issue and the current implementation is functional.


1203-1204: Minor typo in comment.

"If caller passed analysis_state, usage that" should be "use that".

Suggested fix
-        # If caller passed analysis_state, usage that.
+        # If caller passed analysis_state, use that.

1571-1573: Redundant exception object in log.exception call.

log.exception() automatically includes the exception information in the log output, so including {e} in the message is redundant.

Suggested fix
         except Exception as e:
-            log.exception(f"Failed to save {self.current_filepath}: {e}")
+            log.exception(f"Failed to save {self.current_filepath}")
             raise RuntimeError(f"Save failed: {str(e)}") from e

Comment thread ChangeLog.md Outdated
Comment thread faststack/app.py
Comment on lines 771 to +803
saved_path, backup_path = result

# If we overwrote the current file, we need to refresh checks
# But usually we save as a new file or overwrite.
# Use get_active_edit_path logic? ImageEditor handles correct path logic?
# ImageEditor.save_image saves to self.current_filepath
# --- Restore Old Behavior ---

# Invalidate cache for this file
self.display_generation += 1
self.image_cache.clear() # Brute force clear to ensure fresh load
# 1. Close Editor UI
self.ui_state.isEditorOpen = False

# 2. Clear Editor State (release memory)
self.image_editor.clear()

# 3. Refresh List (to see new file or updated timestamp)
self.refresh_image_list()

# 4. Find and Select the saved image
new_index = self.current_index # Default to keeping selection if not found

# Try to find by exact path match
found_index = -1
if saved_path:
try:
target_resolve = saved_path.resolve()
for i, img in enumerate(self.image_files):
try:
# Robust path comparison
if img.path.resolve() == target_resolve:
new_index = i
found_index = i
break
except (OSError, RuntimeError):
# Fallback to string compare
if str(img.path) == str(saved_path):
new_index = i
found_index = i
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Clean up a few unused vars / stray f-string.

backup_path and found_index aren’t used, and the f-string has no placeholders.

🧹 Proposed fix
-            saved_path, backup_path = result
+            saved_path, _backup_path = result
...
-            found_index = -1
...
-            self.update_status_message(f"Image saved")
+            self.update_status_message("Image saved")

Also applies to: 816-816

🧰 Tools
🪛 Ruff (0.14.13)

771-771: Unpacked variable backup_path is never used

Prefix it with an underscore or any other dummy variable pattern

(RUF059)


803-803: Local variable found_index is assigned to but never used

Remove assignment to unused variable found_index

(F841)

🤖 Prompt for AI Agents
In `@faststack/app.py` around lines 771 - 803, Remove unused variables and the
stray f-string: drop the unused backup_path from the unpacking of result and
remove the found_index local (it’s assigned but never used) in the block around
saved_path handling; update the unpack to only capture saved_path and adjust any
subsequent references to found_index (none expected) to use new_index when
needed. Also locate and remove the stray f-string with no placeholders near the
same area (around line ~816) so there are no empty f-strings left in the
function; keep all path-resolution logic using saved_path, new_index,
self.image_files, and img.path.resolve unchanged.

Comment on lines +128 to +131
if pre_exposure_linear is not None:
max_rgb = rgb_linear.max(axis=2)
elif 'max_rgb' not in locals():
max_rgb = rgb_linear.max(axis=2)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Dead code: the elif branch can never execute.

If pre_exposure_linear is not None, line 129 defines max_rgb. If pre_exposure_linear is None, line 106 already defined max_rgb. Therefore, the condition 'max_rgb' not in locals() at line 130 is always False.

Proposed fix
     # 2. Current Near-White Statistics (for Pivot Nudging)
     # This drives the "micro-contrast feel" based on how bright the image IS NOW.
     # Re-calculate max_rgb if we didn't do it earlier (optimization)
     if pre_exposure_linear is not None:
         max_rgb = rgb_linear.max(axis=2)
-    elif 'max_rgb' not in locals():
-         max_rgb = rgb_linear.max(axis=2)
+    # else: max_rgb was already computed at line 106
🤖 Prompt for AI Agents
In `@faststack/imaging/math_utils.py` around lines 128 - 131, The elif branch is
dead because max_rgb is already defined when pre_exposure_linear is None; remove
the redundant elif and simply set max_rgb only when pre_exposure_linear is not
None (i.e., keep the if pre_exposure_linear is not None: max_rgb =
rgb_linear.max(axis=2) and delete the elif 'max_rgb' not in locals() branch),
ensuring the code relies on the earlier definition of max_rgb when
pre_exposure_linear is None; reference variables: pre_exposure_linear, max_rgb,
rgb_linear.

Comment on lines +156 to +164
def _highlight_recover_linear(
rgb_linear: np.ndarray,
amount: float,
*,
pivot: float = 0.7,
k: float = 6.0,
chroma_rolloff: float = 0.15,
headroom_ceiling: float = 1.0,
) -> np.ndarray:
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, find the complete function definition and check if k is used
rg -n '_highlight_recover_linear' --type py -A 80 faststack/imaging/math_utils.py | head -100

Repository: AlanRockefeller/faststack

Length of output: 4110


🏁 Script executed:

# Check all call sites of _highlight_recover_linear across the codebase
rg -n '_highlight_recover_linear\(' --type py

Repository: AlanRockefeller/faststack

Length of output: 1098


🏁 Script executed:

# Also check the specific line in editor.py mentioned in the review
rg -n '_highlight_recover_linear' faststack/ --type py -B 2 -A 2

Repository: AlanRockefeller/faststack

Length of output: 4339


🏁 Script executed:

# Check the actual call to _highlight_recover_linear in editor.py around line 1343
sed -n '1340,1355p' faststack/imaging/editor.py

Repository: AlanRockefeller/faststack

Length of output: 690


🏁 Script executed:

# Get more context around the editor.py call site (lines 1340-1360)
sed -n '1340,1360p' faststack/imaging/editor.py

Repository: AlanRockefeller/faststack

Length of output: 882


Remove unused parameter k from function signature.

The parameter k is declared with a default value and documented as "Shoulder curve steepness," but is never used in the function body. The implementation uses a simple linear recovery approach without any shoulder curve logic. Multiple callers in test_highlight_recovery.py and editor.py pass k=6.0 or k=k explicitly, but it has no effect. Remove the parameter and its docstring reference to eliminate confusion.

Recommended change
 def _highlight_recover_linear(
     rgb_linear: np.ndarray,
     amount: float,
     *,
     pivot: float = 0.7,
-    k: float = 6.0,
     chroma_rolloff: float = 0.15,
     headroom_ceiling: float = 1.0,
 ) -> np.ndarray:
     """Apply highlight recovery using brightness-based rescaling to preserve hue.

Update all call sites in editor.py (line 1343) and all test calls in test_highlight_recovery.py (lines 38, 63, 78, 102, 116, 185, 190) to remove the k= argument.

🧰 Tools
🪛 Ruff (0.14.13)

161-161: Unused function argument: k

(ARG001)

🤖 Prompt for AI Agents
In `@faststack/imaging/math_utils.py` around lines 156 - 164, Remove the unused
parameter k from the _highlight_recover_linear signature and its docstring
reference, and update all callers to stop passing k; specifically, edit the
function _highlight_recover_linear to delete the k: float = 6.0 parameter and
any docstring text like "Shoulder curve steepness", then modify every call site
that currently passes k=... (e.g., calls from editor.py and tests in
test_highlight_recovery.py) to omit that argument so behavior remains unchanged.

Comment on lines +35 to +76
def apply_orientation_to_np(buffer: np.ndarray, orientation: int) -> np.ndarray:
"""Apply EXIF orientation transformation to a numpy image buffer.

Args:
buffer: Image as numpy array (H, W, 3) RGB uint8 or float32
orientation: Orientation value (1-8)

Returns:
Transformed numpy array. Guaranteed to be C-contiguous.
"""
if orientation <= 1:
return buffer

# Apply transformation based on orientation
if orientation == 2:
# Mirrored horizontally
result = np.fliplr(buffer)
elif orientation == 3:
# Rotated 180 degrees
result = np.rot90(buffer, k=2)
elif orientation == 4:
# Mirrored vertically
result = np.flipud(buffer)
elif orientation == 5:
# Mirrored horizontally then rotated 90 CCW
result = np.rot90(np.fliplr(buffer), k=1)
elif orientation == 6:
# Rotated 90 CW (270 CCW)
result = np.rot90(buffer, k=3)
elif orientation == 7:
# Mirrored horizontally then rotated 90 CW
result = np.rot90(np.fliplr(buffer), k=3)
elif orientation == 8:
# Rotated 90 CCW
result = np.rot90(buffer, k=1)
else:
return buffer

# Ensure result is C-contiguous after flip/rotate
if not result.flags['C_CONTIGUOUS']:
result = np.ascontiguousarray(result)
return result
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

find . -name "orientation.py" -path "*/imaging/*" | head -5

Repository: AlanRockefeller/faststack

Length of output: 103


🏁 Script executed:

cat -n ./faststack/imaging/orientation.py | head -100

Repository: AlanRockefeller/faststack

Length of output: 3396


Enforce C-contiguity guarantee on all return paths.

The docstring promises the output is "Guaranteed to be C-contiguous," but the early returns for orientation <= 1 and invalid orientations bypass contiguity normalization. Callers relying on this contract could receive non-contiguous arrays.

Proposed fix
-    if orientation <= 1:
-        return buffer
+    if orientation <= 1:
+        return buffer if buffer.flags["C_CONTIGUOUS"] else np.ascontiguousarray(buffer)
@@
-    else:
-        return buffer
+    else:
+        return buffer if buffer.flags["C_CONTIGUOUS"] else np.ascontiguousarray(buffer)
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def apply_orientation_to_np(buffer: np.ndarray, orientation: int) -> np.ndarray:
"""Apply EXIF orientation transformation to a numpy image buffer.
Args:
buffer: Image as numpy array (H, W, 3) RGB uint8 or float32
orientation: Orientation value (1-8)
Returns:
Transformed numpy array. Guaranteed to be C-contiguous.
"""
if orientation <= 1:
return buffer
# Apply transformation based on orientation
if orientation == 2:
# Mirrored horizontally
result = np.fliplr(buffer)
elif orientation == 3:
# Rotated 180 degrees
result = np.rot90(buffer, k=2)
elif orientation == 4:
# Mirrored vertically
result = np.flipud(buffer)
elif orientation == 5:
# Mirrored horizontally then rotated 90 CCW
result = np.rot90(np.fliplr(buffer), k=1)
elif orientation == 6:
# Rotated 90 CW (270 CCW)
result = np.rot90(buffer, k=3)
elif orientation == 7:
# Mirrored horizontally then rotated 90 CW
result = np.rot90(np.fliplr(buffer), k=3)
elif orientation == 8:
# Rotated 90 CCW
result = np.rot90(buffer, k=1)
else:
return buffer
# Ensure result is C-contiguous after flip/rotate
if not result.flags['C_CONTIGUOUS']:
result = np.ascontiguousarray(result)
return result
def apply_orientation_to_np(buffer: np.ndarray, orientation: int) -> np.ndarray:
"""Apply EXIF orientation transformation to a numpy image buffer.
Args:
buffer: Image as numpy array (H, W, 3) RGB uint8 or float32
orientation: Orientation value (1-8)
Returns:
Transformed numpy array. Guaranteed to be C-contiguous.
"""
if orientation <= 1:
return buffer if buffer.flags["C_CONTIGUOUS"] else np.ascontiguousarray(buffer)
# Apply transformation based on orientation
if orientation == 2:
# Mirrored horizontally
result = np.fliplr(buffer)
elif orientation == 3:
# Rotated 180 degrees
result = np.rot90(buffer, k=2)
elif orientation == 4:
# Mirrored vertically
result = np.flipud(buffer)
elif orientation == 5:
# Mirrored horizontally then rotated 90 CCW
result = np.rot90(np.fliplr(buffer), k=1)
elif orientation == 6:
# Rotated 90 CW (270 CCW)
result = np.rot90(buffer, k=3)
elif orientation == 7:
# Mirrored horizontally then rotated 90 CW
result = np.rot90(np.fliplr(buffer), k=3)
elif orientation == 8:
# Rotated 90 CCW
result = np.rot90(buffer, k=1)
else:
return buffer if buffer.flags["C_CONTIGUOUS"] else np.ascontiguousarray(buffer)
# Ensure result is C-contiguous after flip/rotate
if not result.flags['C_CONTIGUOUS']:
result = np.ascontiguousarray(result)
return result
🤖 Prompt for AI Agents
In `@faststack/imaging/orientation.py` around lines 35 - 76, The function
apply_orientation_to_np returns early for orientation <= 1 and for the else
branch without enforcing C-contiguity contrary to its docstring; update those
return paths to wrap the returned array with np.ascontiguousarray(...) so the
function always returns a C-contiguous numpy array (apply to the early return
for orientation <= 1 and the final else branch, leaving the post-transformation
contiguity check for transformed results intact).

Comment on lines +34 to +44
print("\nBrightness Levels (Linear 0.0 -> 1.0):")
print("Input -> Output (Diff)")
for i in range(steps):
inp = vals[i]
outp = out[0, i, 0]
diff = inp - outp
print(f"{inp:0.2f} -> {outp:0.4f} ({diff:0.4f})")

# The goal is to see significant changes (diff > 0.01) starting from lower levels
# Currently, with pivot 0.75, values below 0.75 should be unchanged (diff=0)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Test lacks assertions—only prints output.

This test runs through the highlight recovery logic but has no assert statements to validate behavior. As written, it will always pass regardless of output. The comments on lines 42-43 describe expected behavior that should be asserted.

💚 Proposed fix to add basic assertions
         for i in range(steps):
             inp = vals[i]
             outp = out[0, i, 0]
             diff = inp - outp
             print(f"{inp:0.2f}   ->  {outp:0.4f}  ({diff:0.4f})")
             
-        # The goal is to see significant changes (diff > 0.01) starting from lower levels
-        # Currently, with pivot 0.75, values below 0.75 should be unchanged (diff=0)
+        # Values below pivot should be mostly unchanged
+        # Higher values should show recovery effect
+        high_val_idx = steps - 2  # 0.9 linear
+        high_diff = vals[high_val_idx] - out[0, high_val_idx, 0]
+        self.assertGreater(high_diff, 0.01, "Highlight recovery should affect bright values")
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
print("\nBrightness Levels (Linear 0.0 -> 1.0):")
print("Input -> Output (Diff)")
for i in range(steps):
inp = vals[i]
outp = out[0, i, 0]
diff = inp - outp
print(f"{inp:0.2f} -> {outp:0.4f} ({diff:0.4f})")
# The goal is to see significant changes (diff > 0.01) starting from lower levels
# Currently, with pivot 0.75, values below 0.75 should be unchanged (diff=0)
print("\nBrightness Levels (Linear 0.0 -> 1.0):")
print("Input -> Output (Diff)")
for i in range(steps):
inp = vals[i]
outp = out[0, i, 0]
diff = inp - outp
print(f"{inp:0.2f} -> {outp:0.4f} ({diff:0.4f})")
# Values below pivot should be mostly unchanged
# Higher values should show recovery effect
high_val_idx = steps - 2 # 0.9 linear
high_diff = vals[high_val_idx] - out[0, high_val_idx, 0]
self.assertGreater(high_diff, 0.01, "Highlight recovery should affect bright values")
🤖 Prompt for AI Agents
In `@faststack/tests/test_highlights_responsiveness.py` around lines 34 - 44, The
test currently only prints values and has no assertions; update
test_highlights_responsiveness.py to assert the expected behavior using the
existing variables (steps, vals, out and computed diff = inp - outp): add
assertions that for indices where vals[i] < 0.75 (the pivot referenced in
comments) diff == 0 (or nearly zero with a small tolerance like 1e-6) and for a
set of lower brightness inputs verify diff > 0.01 to ensure responsiveness; keep
the loop that computes inp/outp/diff but replace/augment prints with assert
statements that use exact or tolerance comparisons so the test will fail on
regressions.

Comment on lines +39 to +65
def test_analysis_decoupling(self):
"""Verify analysis runs before adjustments and is cached via preview path."""
editor = ImageEditor()
# Create a linear image with some headroom
linear = np.ones((100, 100, 3), dtype=np.float32) * 1.2
# sRGB mock indicating some clipping (e.g. 255)
srgb = np.ones((100, 100, 3), dtype=np.uint8) * 255

# Setup editor state to simulate the image being loaded
# We need this because _apply_edits works on self.float_image/preview logic usually,
# but one can pass arr.
# But _apply_edits updates _last_highlight_state.

# Run _apply_edits flow
edits = editor._initial_edits()
edits['highlights'] = -0.5

# _apply_edits expects global self.float_image for some contexts?
# No, it takes img_arr arg.

editor._apply_edits(linear, edits=edits, for_export=False)

# Check cache
self.assertIsNotNone(editor._last_highlight_state)
# Note: update logic might use striding so check rough values
self.assertGreater(editor._last_highlight_state['headroom_pct'], 0.9)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Remove unused srgb fixture.
Ruff flags it as unused; drop it or pass it into the analysis path if that was the intent.

🧹 Proposed fix
-        # sRGB mock indicating some clipping (e.g. 255)
-        srgb = np.ones((100, 100, 3), dtype=np.uint8) * 255
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
def test_analysis_decoupling(self):
"""Verify analysis runs before adjustments and is cached via preview path."""
editor = ImageEditor()
# Create a linear image with some headroom
linear = np.ones((100, 100, 3), dtype=np.float32) * 1.2
# sRGB mock indicating some clipping (e.g. 255)
srgb = np.ones((100, 100, 3), dtype=np.uint8) * 255
# Setup editor state to simulate the image being loaded
# We need this because _apply_edits works on self.float_image/preview logic usually,
# but one can pass arr.
# But _apply_edits updates _last_highlight_state.
# Run _apply_edits flow
edits = editor._initial_edits()
edits['highlights'] = -0.5
# _apply_edits expects global self.float_image for some contexts?
# No, it takes img_arr arg.
editor._apply_edits(linear, edits=edits, for_export=False)
# Check cache
self.assertIsNotNone(editor._last_highlight_state)
# Note: update logic might use striding so check rough values
self.assertGreater(editor._last_highlight_state['headroom_pct'], 0.9)
def test_analysis_decoupling(self):
"""Verify analysis runs before adjustments and is cached via preview path."""
editor = ImageEditor()
# Create a linear image with some headroom
linear = np.ones((100, 100, 3), dtype=np.float32) * 1.2
# Setup editor state to simulate the image being loaded
# We need this because _apply_edits works on self.float_image/preview logic usually,
# but one can pass arr.
# But _apply_edits updates _last_highlight_state.
# Run _apply_edits flow
edits = editor._initial_edits()
edits['highlights'] = -0.5
# _apply_edits expects global self.float_image for some contexts?
# No, it takes img_arr arg.
editor._apply_edits(linear, edits=edits, for_export=False)
# Check cache
self.assertIsNotNone(editor._last_highlight_state)
# Note: update logic might use striding so check rough values
self.assertGreater(editor._last_highlight_state['headroom_pct'], 0.9)
🧰 Tools
🪛 Ruff (0.14.13)

45-45: Local variable srgb is assigned to but never used

Remove assignment to unused variable srgb

(F841)

🤖 Prompt for AI Agents
In `@faststack/tests/test_highlights_v2.py` around lines 39 - 65, The
test_analysis_decoupling test contains an unused local variable srgb which ruff
flags; remove the unused srgb declaration from the test or, if it was intended
to feed the analysis path, pass it into the call that triggers analysis (e.g.,
provide srgb to editor._apply_edits or to whatever ImageEditor method that
consumes an sRGB input) so that the variable is actually used; update assertions
only if the analysis input changes; relevant symbols: test_analysis_decoupling,
srgb, editor._apply_edits, ImageEditor, _initial_edits, and
_last_highlight_state.

Comment on lines 1 to 7
import unittest
from unittest.mock import MagicMock
from concurrent.futures import Future
from unittest.mock import MagicMock, patch
import sys

# Mock config before importing prefetcher
sys.modules['faststack.config'] = MagicMock()
from faststack.imaging.prefetch import Prefetcher
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

Missing Future import causes runtime error.

The Future class is used on line 31 (spec=Future) but is not imported. This will raise a NameError when the test runs.

🐛 Proposed fix
 import unittest
 from unittest.mock import MagicMock, patch
 import sys
+from concurrent.futures import Future

 # Mock config before importing prefetcher
 sys.modules['faststack.config'] = MagicMock()
🤖 Prompt for AI Agents
In `@faststack/tests/test_prefetch_logic.py` around lines 1 - 7, The test imports
use Future in a type/spec at spec=Future but never imports it, causing a
NameError at runtime; fix by adding an import for concurrent.futures.Future (or
from concurrent.futures import Future) at the top of
faststack/tests/test_prefetch_logic.py so the spec=Future reference in the test
(around the Prefetcher-related mocks) resolves correctly.

Comment thread faststack/ui/provider.py
Comment on lines +823 to +829
"""Returns highlight analysis state for UI display.

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

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

Docstring lists obsolete highlight keys.
The docstring still mentions clipped_pct/near_white_pct, but the property returns source_clipped_pct/current_nearwhite_pct. Update the docstring to avoid UI/API confusion.

📝 Docstring fix
-        - clipped_pct: Fraction of pixels clipped in the SOURCE image (JPEG flat-top @ 254+)
-        - near_white_pct: Fraction of pixels currently near white in the processed state.
+        - 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.
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
"""Returns highlight analysis state for UI display.
Returns dict with:
- headroom_pct: Fraction of pixels with recoverable data above 1.0 (16-bit sources)
- clipped_pct: Fraction of pixels clipped in the SOURCE image (JPEG flat-top @ 254+)
- near_white_pct: Fraction of pixels currently near white in the processed state.
"""
"""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.
"""
🤖 Prompt for AI Agents
In `@faststack/ui/provider.py` around lines 823 - 829, Update the docstring for
the highlight analysis return value in provider.py to match the actual keys
returned by the function: replace references to clipped_pct and near_white_pct
with source_clipped_pct and current_nearwhite_pct (keep headroom_pct as-is), and
adjust the descriptions to refer to the SOURCE image for source_clipped_pct and
the processed/current state for current_nearwhite_pct so the docstring matches
the returned dict keys from this highlight analysis routine.

@@ -0,0 +1,67 @@
import unittest
import numpy as np
from faststack.imaging.editor import ImageEditor, _apply_headroom_shoulder
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

rg -n "_apply_headroom_shoulder" faststack -g'*.py'

Repository: AlanRockefeller/faststack

Length of output: 2109


🏁 Script executed:

sed -n '1,50p' faststack/imaging/editor.py | grep -A 20 "_apply_headroom_shoulder\|__all__"

Repository: AlanRockefeller/faststack

Length of output: 515


🏁 Script executed:

sed -n '20,30p' faststack/tests/test_highlight_recovery.py

Repository: AlanRockefeller/faststack

Length of output: 291


Fix import source to use the defining module.
_apply_headroom_shoulder is defined in faststack.imaging.math_utils, not faststack.imaging.editor. While the current import happens to work because editor.py imports it, imports should reference the defining module for maintainability. Align with the pattern used in test_rolloff.py:

Corrected import
-from faststack.imaging.editor import ImageEditor, _apply_headroom_shoulder
+from faststack.imaging.editor import ImageEditor
+from faststack.imaging.math_utils import _apply_headroom_shoulder
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
from faststack.imaging.editor import ImageEditor, _apply_headroom_shoulder
from faststack.imaging.editor import ImageEditor
from faststack.imaging.math_utils import _apply_headroom_shoulder
🤖 Prompt for AI Agents
In `@tests/test_highlights_v2.py` at line 3, The test imports
_apply_headroom_shoulder from the wrong module; update the import in
tests/test_highlights_v2.py to import _apply_headroom_shoulder from its defining
module faststack.imaging.math_utils (keep ImageEditor import as-is if needed) so
the test references the symbol _apply_headroom_shoulder from the correct source
module for maintainability and consistency with test_rolloff.py.

Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@faststack/imaging/editor.py`:
- Around line 1710-1722: The fallback branch that assigns exif_bytes from
original_image.info.get("exif") can leave a non-1 Orientation tag; update the
logic so that when using original_image's EXIF you pass it through
sanitize_exif_orientation before assigning to exif_bytes. Locate the block
around transforms_applied / _source_exif_bytes handling (methods/variables:
_get_sanitized_exif_bytes, _source_exif_bytes, original_image) and replace the
direct assignment from original_image.info.get("exif") with a sanitized value
via sanitize_exif_orientation(original_image.info.get("exif")) while preserving
the existing precedence and None handling.
- Around line 515-544: The cached_preview path lacks the EXIF orientation guard
applied to the master float_image; update the cached_preview branch to check the
existing orientation variable and, when orientation != 1, call
apply_orientation_to_np on preview_arr (or on the uint8 buffer before astype) so
the preview matches the oriented master, then convert to float32/scale into
loaded_float_preview; reference cached_preview, preview_arr,
loaded_float_preview, orientation, and apply_orientation_to_np to locate and
implement the change.
🧹 Nitpick comments (1)
faststack/app.py (1)

766-766: Drop redundant {e} in log.exception(...) messages.
log.exception already appends exception context/trace; keeping {e} adds noise.

♻️ Suggested tweak
-            log.exception(f"Unexpected error during save: {e}")
+            log.exception("Unexpected error during save")
...
-            log.exception(f"execute_crop: Unexpected error during save: {e}")
+            log.exception("execute_crop: Unexpected error during save")
...
-            log.exception(f"quick_auto_levels: Unexpected error during save: {e}")
+            log.exception("quick_auto_levels: Unexpected error during save")
...
-            log.exception(f"quick_auto_white_balance: Unexpected error during save: {e}")
+            log.exception("quick_auto_white_balance: Unexpected error during save")

Also applies to: 3691-3691, 3869-3869, 3946-3946

Comment on lines 515 to +544
# --- Create Float Preview ---
# Use the cached, display-sized preview if available to speed up
if cached_preview:
# cached_preview.buffer is uint8
preview_arr = np.frombuffer(cached_preview.buffer, dtype=np.uint8).reshape(
(cached_preview.height, cached_preview.width, 3)
)
preview_arr = np.frombuffer(
cached_preview.buffer, dtype=np.uint8
).reshape((cached_preview.height, cached_preview.width, 3))

# IMPORTANT: The cached_preview coming from the viewer is already "cooked"
# (it has Color Management / Saturation applied).
# Our editor expects a "raw" float buffer (non-managed) as its starting point for _apply_edits.
# To prevent a color "pop" when edits start, we have two choices:
# 1. "Un-cook" the preview (expensive/inaccurate).
# 2. Use the cooked preview for the VERY FIRST frame, but immediately
# re-render from the master float_image in the background.
# Since we already apply EXIF orientation to the master float_image above,
# we should also ensure the preview_arr matches orientation if it doesn't already.
# Generally, the Prefetcher already applies orientation to the cached preview.
loaded_float_preview = preview_arr.astype(np.float32) / 255.0
else:
# Downscale from float_image
# Simple striding for speed or creating a PIL thumbnail from original?
# PIL thumbnail is faster and better quality usually.
# Downscale from float_image (which now has orientation applied)
thumb = loaded_original.copy()
thumb.thumbnail((1920, 1080))
thumb_rgb = thumb.convert("RGB")
loaded_float_preview = np.array(thumb_rgb).astype(np.float32) / 255.0

# If we applied orientation to the original, the thumbnail will already be correct
# because we derived it from loaded_original AFTER exif_transpose.
# If we derived from cached_preview, we might still need to apply orientation.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

rg -n "exif|orientation|exif_transpose|apply_orientation|apply_exif_orientation" faststack/imaging/prefetch.py

Repository: AlanRockefeller/faststack

Length of output: 1292


🏁 Script executed:

# Get the broader context of the code in editor.py around lines 515-544
head -550 faststack/imaging/editor.py | tail -80

Repository: AlanRockefeller/faststack

Length of output: 4603


🏁 Script executed:

# Search for orientation-related code in editor.py
rg -n "orientation|exif" faststack/imaging/editor.py | head -30

Repository: AlanRockefeller/faststack

Length of output: 2041


🏁 Script executed:

# Check what cached_preview is and how it's populated
rg -n "cached_preview" faststack/imaging/editor.py | head -20

Repository: AlanRockefeller/faststack

Length of output: 1054


🏁 Script executed:

# Check where cached_preview is created and passed to the editor
rg -n "DecodedImage\|cached_preview.*=" faststack/imaging/editor.py | head -20

Repository: AlanRockefeller/faststack

Length of output: 51


🏁 Script executed:

# Search for where cached_preview is set/created in prefetch or other sources
rg -n "DecodedImage.*=" faststack/imaging/prefetch.py | head -20

Repository: AlanRockefeller/faststack

Length of output: 51


🏁 Script executed:

# Check the full load_image method signature and see all parameters available
sed -n '400,430p' faststack/imaging/editor.py

Repository: AlanRockefeller/faststack

Length of output: 1178


🏁 Script executed:

# Verify if orientation variable is in scope when cached_preview is processed
sed -n '490,525p' faststack/imaging/editor.py

Repository: AlanRockefeller/faststack

Length of output: 1954


🏁 Script executed:

# Check if apply_orientation_to_np is imported in editor.py
rg -n "apply_orientation_to_np" faststack/imaging/editor.py

Repository: AlanRockefeller/faststack

Length of output: 229


🏁 Script executed:

# Check what's imported at the top of editor.py
head -50 faststack/imaging/editor.py | grep -n "import\|from"

Repository: AlanRockefeller/faststack

Length of output: 652


Add explicit orientation check for cached_preview to match master float_image handling.

The prefetcher generally applies EXIF orientation to cached previews during decode, but the editor code acknowledges this with "generally"—indicating it's not guaranteed. The master float_image is explicitly checked and oriented (line 504), but the cached_preview path has no corresponding check or fallback. Since orientation is already calculated and apply_orientation_to_np is imported, add an explicit orientation guard to ensure preview_arr is oriented when the master buffer is.

🔧 Suggested fix
 if cached_preview:
     # cached_preview.buffer is uint8
     preview_arr = np.frombuffer(
         cached_preview.buffer, dtype=np.uint8
     ).reshape((cached_preview.height, cached_preview.width, 3))

+    # Ensure preview matches master float_image orientation
+    if orientation > 1:
+        preview_arr = apply_orientation_to_np(preview_arr, orientation)
🤖 Prompt for AI Agents
In `@faststack/imaging/editor.py` around lines 515 - 544, The cached_preview path
lacks the EXIF orientation guard applied to the master float_image; update the
cached_preview branch to check the existing orientation variable and, when
orientation != 1, call apply_orientation_to_np on preview_arr (or on the uint8
buffer before astype) so the preview matches the oriented master, then convert
to float32/scale into loaded_float_preview; reference cached_preview,
preview_arr, loaded_float_preview, orientation, and apply_orientation_to_np to
locate and implement the change.

Comment on lines 1710 to +1722
# Determine EXIF for sidecar - prefer source EXIF (from paired JPEG)
exif_bytes = None
if transforms_applied:
# Use sanitized EXIF (orientation reset to 1)
exif_bytes = self._get_sanitized_exif_bytes()
elif self._source_exif_bytes:
# Use cached source EXIF from paired JPEG
exif_bytes = self._source_exif_bytes
# Must sanitize orientation because we baked it on load!
exif_bytes = sanitize_exif_orientation(self._source_exif_bytes)
elif self.original_image:
# Fallback to current image's EXIF (may be empty for TIFFs)
exif_bytes = self.original_image.info.get('exif')
exif_bytes = self.original_image.info.get("exif")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Sanitize EXIF for developed sidecar even when no transforms.

At Line 1721 you fall back to original_image.info["exif"] without sanitizing orientation, but orientation is already baked on load. This can reintroduce a non‑1 Orientation tag and cause double-rotation in viewers. Please sanitize here as well.

🐛 Proposed fix
-                elif self.original_image:
-                    # Fallback to current image's EXIF (may be empty for TIFFs)
-                    exif_bytes = self.original_image.info.get("exif")
+                elif self.original_image:
+                    # Fallback to current image's EXIF (may be empty for TIFFs)
+                    exif_bytes = sanitize_exif_orientation(self.original_image.info.get("exif"))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Determine EXIF for sidecar - prefer source EXIF (from paired JPEG)
exif_bytes = None
if transforms_applied:
# Use sanitized EXIF (orientation reset to 1)
exif_bytes = self._get_sanitized_exif_bytes()
elif self._source_exif_bytes:
# Use cached source EXIF from paired JPEG
exif_bytes = self._source_exif_bytes
# Must sanitize orientation because we baked it on load!
exif_bytes = sanitize_exif_orientation(self._source_exif_bytes)
elif self.original_image:
# Fallback to current image's EXIF (may be empty for TIFFs)
exif_bytes = self.original_image.info.get('exif')
exif_bytes = self.original_image.info.get("exif")
# Determine EXIF for sidecar - prefer source EXIF (from paired JPEG)
exif_bytes = None
if transforms_applied:
# Use sanitized EXIF (orientation reset to 1)
exif_bytes = self._get_sanitized_exif_bytes()
elif self._source_exif_bytes:
# Use cached source EXIF from paired JPEG
# Must sanitize orientation because we baked it on load!
exif_bytes = sanitize_exif_orientation(self._source_exif_bytes)
elif self.original_image:
# Fallback to current image's EXIF (may be empty for TIFFs)
exif_bytes = sanitize_exif_orientation(self.original_image.info.get("exif"))
🤖 Prompt for AI Agents
In `@faststack/imaging/editor.py` around lines 1710 - 1722, The fallback branch
that assigns exif_bytes from original_image.info.get("exif") can leave a non-1
Orientation tag; update the logic so that when using original_image's EXIF you
pass it through sanitize_exif_orientation before assigning to exif_bytes. Locate
the block around transforms_applied / _source_exif_bytes handling
(methods/variables: _get_sanitized_exif_bytes, _source_exif_bytes,
original_image) and replace the direct assignment from
original_image.info.get("exif") with a sanitized value via
sanitize_exif_orientation(original_image.info.get("exif")) while preserving the
existing precedence and None handling.

@AlanRockefeller AlanRockefeller merged commit e4e8ab8 into main Jan 27, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant