Skip to content

[codex] Move preview pixmap creation to UI thread - #107

Merged
Ahmed-Hindy merged 1 commit into
mainfrom
dev/preview-worker-image-data
Jun 14, 2026
Merged

[codex] Move preview pixmap creation to UI thread#107
Ahmed-Hindy merged 1 commit into
mainfrom
dev/preview-worker-image-data

Conversation

@Ahmed-Hindy

@Ahmed-Hindy Ahmed-Hindy commented Jun 14, 2026

Copy link
Copy Markdown
Owner

Summary

  • move preview pixel marshaling into a dedicated UI helper
  • have PreviewWorker emit neutral PreviewImageData instead of constructing QPixmap in the worker thread
  • convert preview image data to QPixmap in PreviewWidget before display/export/fullscreen use

Root cause

PreviewWorker.run() delegated ImageBuf preparation to the shared frame pipeline, but still owned NumPy-to-QImage/QPixmap conversion inline. That kept a GUI-facing QPixmap creation step inside the worker thread and made the worker/UI boundary blurrier than the shared prep cleanup intended.

Validation

  • uv --system-certs sync --extra dev
  • uv --system-certs run pytest tests/test_ui_widgets.py
  • uv --system-certs run ruff check src/renderkit/ui/widgets.py src/renderkit/ui/preview_image.py tests/test_ui_widgets.py

Summary by CodeRabbit

  • Refactor

    • Enhanced internal preview image processing system for improved maintainability and consistency across the application.
  • Tests

    • Added comprehensive unit tests for preview image conversion, covering color format handling, channel support, and error conditions.

@coderabbitai

coderabbitai Bot commented Jun 14, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

A new preview_image.py module is introduced with PreviewImageData dataclass and three conversion functions (imagebuf_to_preview_image, preview_image_to_qimage, preview_image_to_pixmap). widgets.py is refactored to delegate all pixel-buffer processing to this module, changing PreviewWorker.preview_ready to emit a generic object. Tests are added for all conversion paths.

Changes

Preview Image Conversion Module and Widget Refactor

Layer / File(s) Summary
PreviewImageData model and ImageBuf→Qt conversion pipeline
src/renderkit/ui/preview_image.py
Adds the PreviewImageData frozen dataclass (pixels, width, height, channels), imagebuf_to_preview_image() with HWC reshape/dtype normalization, preview_image_to_qimage(), preview_image_to_pixmap(), and _qimage_format() with fallback Qt enum lookup.
Widget refactor to use the new module + tests
src/renderkit/ui/widgets.py, tests/test_ui_widgets.py
Removes inline numpy pixel conversion from PreviewWorker.run(), changes preview_ready signal to Signal(object), wires imagebuf_to_preview_image in the worker and preview_image_to_pixmap in _on_preview_ready. Tests cover RGB/RGBA conversion, empty-pixel rejection, unsupported channel count, pixmap construction, and the updated worker signal type assertion.

Sequence Diagram(s)

sequenceDiagram
  participant PreviewWorker
  participant imagebuf_to_preview_image
  participant PreviewWidget
  participant preview_image_to_pixmap

  rect rgba(70, 130, 180, 0.5)
    Note over PreviewWorker: Worker thread
    PreviewWorker->>imagebuf_to_preview_image: ImageBuf
    imagebuf_to_preview_image-->>PreviewWorker: PreviewImageData
    PreviewWorker-->>PreviewWidget: preview_ready(PreviewImageData)
  end

  rect rgba(60, 179, 113, 0.5)
    Note over PreviewWidget: UI thread
    PreviewWidget->>preview_image_to_pixmap: PreviewImageData
    preview_image_to_pixmap-->>PreviewWidget: QPixmap
    PreviewWidget->>PreviewWidget: store QPixmap, repaint
  end
Loading

Estimated code review effort

🎯 2 (Simple) | ⏱️ ~10 minutes

Poem

🐇 Hop, hop! The pixels align,
From floats they clip to uint8 fine.
No more numpy in the widget's lair,
PreviewImageData floats through the air.
RGB and RGBA, neatly packed,
A clean conversion path—nothing lacks! 🎨

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately describes the main change: moving QPixmap creation from worker thread to UI thread, which aligns with the PR's primary objective and the refactoring performed across preview_image.py, widgets.py, and tests.
Docstring Coverage ✅ Passed Docstring coverage is 88.24% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch dev/preview-worker-image-data

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.

@sonarqubecloud

Copy link
Copy Markdown

@codecov-commenter

Copy link
Copy Markdown

⚠️ Please install the 'codecov app svg image' to ensure uploads and comments are reliably processed by Codecov.

Codecov Report

❌ Patch coverage is 32.14286% with 38 lines in your changes missing coverage. Please review.

Files with missing lines Patch % Lines
src/renderkit/ui/preview_image.py 29.41% 36 Missing ⚠️
src/renderkit/ui/widgets.py 60.00% 2 Missing ⚠️

📢 Thoughts on this report? Let us know!

@Ahmed-Hindy
Ahmed-Hindy marked this pull request as ready for review June 14, 2026 19:58
@Ahmed-Hindy Ahmed-Hindy self-assigned this Jun 14, 2026

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🧹 Nitpick comments (2)
src/renderkit/ui/preview_image.py (2)

39-42: ⚡ Quick win

Redundant dtype check since get_pixels(oiio.FLOAT) always returns float32.

Line 24 calls buf.get_pixels(oiio.FLOAT), which always returns a np.float32 array regardless of the buffer's internal format. Therefore, the condition if image.dtype != np.uint8: on line 39 will always be True, making the check redundant. Additionally, the astype(np.float32, copy=False) on line 40 is a no-op since image is already float32.

While this defensive code doesn't cause incorrect behavior, it adds unnecessary complexity.

♻️ Simplify the conversion logic
-    if image.dtype != np.uint8:
-        image_f32 = image.astype(np.float32, copy=False)
-        image = np.clip(image_f32, 0.0, 1.0)
-        image = (image * np.float32(255.0)).astype(np.uint8)
+    # get_pixels(FLOAT) returns float32 in [0,1] for typical images, but HDR can exceed
+    image = np.clip(image, 0.0, 1.0)
+    image = (image * np.float32(255.0)).astype(np.uint8)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/renderkit/ui/preview_image.py` around lines 39 - 42, Since
buf.get_pixels(oiio.FLOAT) always returns a np.float32 array, the condition `if
image.dtype != np.uint8:` will always evaluate to True, making the check
redundant. Additionally, the astype(np.float32, copy=False) call is a no-op
since image is already float32. Remove the redundant dtype check and the
unnecessary astype call, keeping only the np.clip operation and the final
conversion to uint8 via astype(np.uint8). This simplifies the conversion logic
from a conditional block to a direct float-to-uint8 conversion path.

72-89: 💤 Low value

Consider testing the error paths in _qimage_format.

The fallback logic for Qt format resolution (lines 80-89) handles compatibility across Qt versions, but the error path at line 88 ("Qt image format is unavailable") is not tested. While this is unlikely to occur in normal operation, testing it would ensure the error message is helpful and the function fails gracefully.

Add a test that mocks QImage to return None for both Format and the direct attribute, verifying the ValueError is raised with the expected message.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/renderkit/ui/preview_image.py` around lines 72 - 89, Add a test case for
the `_qimage_format` function that verifies the error path is properly handled
when Qt image format is unavailable. Mock the QImage class to return None for
both the Format attribute lookup and the direct format attribute lookup (for
unsupported channel values, test with channels=3 or channels=4). The test should
assert that a ValueError is raised with the expected message "Qt image format is
unavailable: {name}" to ensure the function fails gracefully and provides a
helpful error message when neither fallback path succeeds.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/renderkit/ui/preview_image.py`:
- Around line 29-30: Add a test case for the `imagebuf_to_preview_image()`
function that covers the 1D-to-3D reshape code path. Create a test where
`buf.get_pixels()` returns a 1D array with a length equal to height × width ×
nchannels, then verify that the function correctly reshapes it into a 3D array
with dimensions (spec.height, spec.width, spec.nchannels). This will exercise
the currently untested reshape logic at lines 29-30 and ensure it handles the
edge case correctly.

---

Nitpick comments:
In `@src/renderkit/ui/preview_image.py`:
- Around line 39-42: Since buf.get_pixels(oiio.FLOAT) always returns a
np.float32 array, the condition `if image.dtype != np.uint8:` will always
evaluate to True, making the check redundant. Additionally, the
astype(np.float32, copy=False) call is a no-op since image is already float32.
Remove the redundant dtype check and the unnecessary astype call, keeping only
the np.clip operation and the final conversion to uint8 via astype(np.uint8).
This simplifies the conversion logic from a conditional block to a direct
float-to-uint8 conversion path.
- Around line 72-89: Add a test case for the `_qimage_format` function that
verifies the error path is properly handled when Qt image format is unavailable.
Mock the QImage class to return None for both the Format attribute lookup and
the direct format attribute lookup (for unsupported channel values, test with
channels=3 or channels=4). The test should assert that a ValueError is raised
with the expected message "Qt image format is unavailable: {name}" to ensure the
function fails gracefully and provides a helpful error message when neither
fallback path succeeds.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: bb62f7a1-8326-4442-85df-dfb4c6fa4734

📥 Commits

Reviewing files that changed from the base of the PR and between 38dd1c5 and fab25cd.

📒 Files selected for processing (3)
  • src/renderkit/ui/preview_image.py
  • src/renderkit/ui/widgets.py
  • tests/test_ui_widgets.py

Comment on lines +29 to +30
if image.ndim == 1:
image = image.reshape((spec.height, spec.width, spec.nchannels))

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:

#!/bin/bash
# Description: Check for tests that exercise the 1D reshape path

rg -n 'ndim.*==.*1' --type=py tests/

Repository: Ahmed-Hindy/renderkit

Length of output: 286


🏁 Script executed:

# First, let's understand the preview_image.py file and what function contains lines 29-30
cat -n src/renderkit/ui/preview_image.py | head -40

Repository: Ahmed-Hindy/renderkit

Length of output: 1508


🏁 Script executed:

# Check if there are tests specifically for preview_image.py
fd -e py tests/ | xargs grep -l "preview_image"

Repository: Ahmed-Hindy/renderkit

Length of output: 397


🏁 Script executed:

# Look at the test files with ndim==1 to see if they test preview_image
cat -n tests/test_integration_real_files.py | sed -n '85,135p'

Repository: Ahmed-Hindy/renderkit

Length of output: 2491


🏁 Script executed:

# Search for tests that import or use imagebuf_to_preview_image
rg -n "imagebuf_to_preview_image" tests/

Repository: Ahmed-Hindy/renderkit

Length of output: 815


🏁 Script executed:

# Check if there are any tests for preview_image module
rg -n "from.*preview_image|import.*preview_image" tests/

Repository: Ahmed-Hindy/renderkit

Length of output: 134


🏁 Script executed:

# Look at test_color_space.py around line 40
cat -n tests/test_color_space.py | sed -n '30,50p'

Repository: Ahmed-Hindy/renderkit

Length of output: 830


🏁 Script executed:

# Look at the existing preview_image tests to see if they cover 1D arrays
cat -n tests/test_ui_widgets.py | sed -n '95,180p'

Repository: Ahmed-Hindy/renderkit

Length of output: 3431


Add test coverage for 1D array reshape in imagebuf_to_preview_image().

The 1D-to-3D reshape logic (lines 29–30) is not exercised by the existing test suite. Current tests for imagebuf_to_preview_image() only pass 3D pixel arrays, leaving this code path untested. The team's use of the same reshape pattern in other modules (e.g., _buf_to_array() in test_color_space.py) confirms this is a real edge case. Add a test case where buf.get_pixels() returns a 1D array to ensure the reshape works correctly.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/renderkit/ui/preview_image.py` around lines 29 - 30, Add a test case for
the `imagebuf_to_preview_image()` function that covers the 1D-to-3D reshape code
path. Create a test where `buf.get_pixels()` returns a 1D array with a length
equal to height × width × nchannels, then verify that the function correctly
reshapes it into a 3D array with dimensions (spec.height, spec.width,
spec.nchannels). This will exercise the currently untested reshape logic at
lines 29-30 and ensure it handles the edge case correctly.

@Ahmed-Hindy
Ahmed-Hindy merged commit 33593d0 into main Jun 14, 2026
11 checks passed
@Ahmed-Hindy
Ahmed-Hindy deleted the dev/preview-worker-image-data branch June 14, 2026 22:50
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.

2 participants