Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 26 additions & 4 deletions docs/USER_GUIDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,8 @@ Toolbar buttons, left to right:
* **Add files** / **Add folder**: load individual images or every image in a folder. Pick a folder that only holds *other* folders and NegPy reveals it in the Library section instead of reporting that it found nothing. Dropping a folder on the window does the same.
* **Clear all**: unload everything (or, when several frames are selected, unload just those).
* **Hot Folder**: watches the current folder and auto-loads new files as they appear, handy when a scanner or tethering app drops files into a directory. While it is on, the "Working…" import popup stays hidden so each new frame does not raise a window; the status line over the canvas still reports the import.
* **RGB Scan**: treats the folder as red/green/blue exposure triplets and assembles each frame from three shots (for narrowband trichrome scanning). Right-click a frame → **Edit RGB Triplet…** to assign the three files by hand.
* **Half Frame**: splits each scan into two frames (for half-frame cameras), edited and metered separately. When enabled, a rectangle editor opens on the current scan: drag the green box to crop (everything outside is discarded), drag the orange line to set the split, and use the **Cut thickness** slider to discard a band centered on the split (the physical black separator between the two exposures). The setting is saved and applied to every half-frame split from then on, regardless of how the scans were acquired (SANE scanner, camera copy-stand, or folder import). The **Adjust Half Frame** toolbutton (next to Half Frame) re-opens the editor on the current scan to fine-tune. Auto-detection of the gutter still seeds the initial split position.
* **RGB Scan**: treats the folder as red/green/blue exposure triplets and assembles each frame from three shots (for narrowband trichrome scanning). Right-click a frame → **Edit RGB Triplet…** to assign the three files by hand. An assembled frame carries the three-dot badge described under [Triage](#triage-culling-the-roll).
* **Half Frame**: splits each scan into two frames (for half-frame cameras), edited and metered separately, each carrying a badge that shows which half it is. When enabled, a rectangle editor opens on the current scan: drag the green box to crop (everything outside is discarded), drag the orange line to set the split, and use the **Cut thickness** slider to discard a band centered on the split (the physical black separator between the two exposures). The setting is saved and applied to every half-frame split from then on, regardless of how the scans were acquired (SANE scanner, camera copy-stand, or folder import). The **Adjust Half Frame** toolbutton (next to Half Frame) re-opens the editor on the current scan to fine-tune. Auto-detection of the gutter still seeds the initial split position.
* **Apply (clone)**: copy the current frame's settings to selected frames or the whole roll. You choose which aspects in a dialog (crop and rotation are always per-image).
* **Sheet filter** (funnel): show *All frames*, *Keepers only*, or *Hide rejected*.
* **Sort**: by Name or Date, ascending or descending.
Expand Down Expand Up @@ -114,13 +114,13 @@ Right-clicking **empty space** in the film strip offers **Add files**, **Add fol

#### Stitching a frame from several shots

If one negative was captured in overlapping pieces (a copy stand at higher magnification than the frame), select the pieces and right-click → **Stitch selected frames**. NegPy finds the overlap, matches brightness across the seam and replaces the parts with a single wide composite named *a+b (Stitch)*. The parts' own edits stay on file, so right-click → **Unstitch** puts them back untouched. The registration is saved with the session and replayed on the next launch, so re-opening a composite costs nothing.
If one negative was captured in overlapping pieces (a copy stand at higher magnification than the frame), select the pieces and right-click → **Stitch selected frames**. NegPy finds the overlap, matches brightness across the seam and replaces the parts with a single wide composite named *a+b (Stitch)*, badged on the sheet so you can tell it from a plain frame. The parts' own edits stay on file, so right-click → **Unstitch** puts them back untouched. The registration is saved with the session and replayed on the next launch, so re-opening a composite costs nothing.

This works on RGB-scan frames too: turn on **RGB Scan** first so each piece is already assembled from its own R/G/B triplet, then stitch the assembled frames. Each part keeps its own three exposures — nothing is shared between parts.

#### Merging bracketed exposures (HDR)

A slide's density runs deeper than one camera exposure can record. Expose for the highlights and the darkest parts of the frame sit in sensor noise; expose for those and the bright parts blow. Bracket the capture instead — several shots of the same slide a stop apart — then select them and right-click → **Merge exposures (HDR)**. They collapse into one frame named *a +4 (HDR)* that carries the whole range. Right-click → **Unmerge exposures** puts the originals back; their own edits are untouched.
A slide's density runs deeper than one camera exposure can record. Expose for the highlights and the darkest parts of the frame sit in sensor noise; expose for those and the bright parts blow. Bracket the capture instead — several shots of the same slide a stop apart — then select them and right-click → **Merge exposures (HDR)**. They collapse into one frame named *a +4 (HDR)* that carries the whole range, badged on the sheet so a merge is never mistaken for a single capture. Right-click → **Unmerge exposures** puts the originals back; their own edits are untouched.

Nothing needs to be set up beforehand. NegPy measures the exposures from the images themselves rather than trusting shutter tags (several supported scanner formats have none), works out how many stops apart they are, and registers them to each other in case the camera shifted between frames. The result is saved with the session, so re-opening a merged frame costs nothing.

Expand Down Expand Up @@ -167,6 +167,28 @@ Right-click a thumbnail (or use keyboard shortcuts) to mark frames while you rev

Marks apply to a multi-selection and persist across sessions. A badge in the top-right corner instead flags a frame that failed to decode.

#### Reading the badges

Each corner of a thumbnail means one thing, so the marks never compete:

| Corner | Badge | Means |
|---|---|---|
| Bottom-right | check | keeper |
| Bottom-right | cross, frame heavily dimmed | rejected |
| Top-right | exclamation | the file failed to decode — click to retry |
| Bottom-left | *see below* | the frame was built from more than one file |

The bottom-left badge is grey, not red, because it reports what the frame *is* rather than something you marked. Its glyph says which kind:

| Glyph | Frame |
|---|---|
| Two overlapping panes | a stitched composite ([§Stitching](#stitching-a-frame-from-several-shots)) |
| Three stacked bars | a merged bracket ([§Merging](#merging-bracketed-exposures-hdr)) |
| Three red/green/blue dots | an RGB-scan triplet |
| A split rectangle, one side filled | one half of a half-frame scan — the filled side is which half |

Hover any thumbnail and the tooltip says the same thing in words, with the frame count: *HDR merge of 5 exposures*, *Stitched composite of 3 frames*.

The right-click menu also offers **Copy/Paste Settings** (with or without normalization bounds), **Reset Settings**, **Apply settings…**, and per-frame export.

---
Expand Down
36 changes: 35 additions & 1 deletion negpy/desktop/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
from negpy.infrastructure.display.color_spaces import WORKING_COLOR_SPACE
from negpy.infrastructure.storage.repository import StorageRepository
from negpy.kernel.system.config import APP_CONFIG
from negpy.kernel.system.text import count_of
from negpy.services.assets.flatfield import FlatFieldProfiles
from negpy.services.assets.search import facts_for, match, parse_query
from negpy.services.assets.sidecar import load_or_promote
Expand Down Expand Up @@ -239,6 +240,38 @@ def _asset_mtime(asset: Dict[str, Any]) -> float:
return 0.0


def composite_kind(asset: Dict[str, Any]) -> str:
"""Which multi-file construction an asset is: stitch, hdr, rgb, half, or "" for a
plain frame.

Order is load-bearing: a stitch of triplets also carries the primary part's
green/blue pair (``controller._on_stitch_registered``), so it must be tested first.
"""
if asset.get("stitch_paths"):
return "stitch"
if asset.get("hdr_paths"):
return "hdr"
if asset.get("green_path") and asset.get("blue_path"):
return "rgb"
if asset.get("half"):
return "half"
return ""


def composite_summary(asset: Dict[str, Any]) -> str:
"""One tooltip line naming what a frame is built from. Empty for a plain frame."""
kind = composite_kind(asset)
if kind == "stitch":
return f"Stitched composite of {count_of(len(asset['stitch_paths']) + 1, 'frame')}"
if kind == "hdr":
return f"HDR merge of {count_of(len(hdr_frame_paths(asset)), 'exposure')}"
if kind == "rgb":
return "RGB-scan triplet"
if kind == "half":
return f"Half-frame split ({int(asset['half'])} of 2)"
return ""


class AssetListModel(QAbstractListModel):
"""
Model for the uploaded files list with thumbnail support.
Expand Down Expand Up @@ -374,7 +407,8 @@ def data(self, index: QModelIndex, role: int = Qt.ItemDataRole.DisplayRole) -> A
failed = file_info.get("decode_failed")
if failed:
return f"{file_info['path']}\nFailed to load: {failed}\nClick to retry."
return file_info["path"]
summary = composite_summary(file_info)
return f"{file_info['path']}\n{summary}" if summary else file_info["path"]

if role == Qt.ItemDataRole.UserRole:
return file_info
Expand Down
45 changes: 43 additions & 2 deletions negpy/desktop/view/sidebar/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@

from negpy.kernel.system.text import count_of
from negpy.desktop.controller import AppController
from negpy.desktop.session import _source_effective_bounds
from negpy.desktop.session import _source_effective_bounds, composite_kind
from negpy.desktop.view.confirm import confirm_unload
from negpy.features.hdr.logic import anchor_choices
from negpy.features.hdr.models import hdr_frame_paths
Expand Down Expand Up @@ -70,11 +70,17 @@ class _ThumbnailDelegate(QStyledItemDelegate):
image is shown full-brightness with a white frame while the others are dimmed; a
dirty active file gets an accent line along the image's bottom edge. Triage marks
are small bottom-right badges: check = keeper, cross + heavy dim = rejected; the
top-right badge is reserved for decode failures."""
top-right badge is reserved for decode failures; the bottom-left badge says the frame
was built from several files (stitch, HDR merge, RGB triplet, half-frame split)."""

_MARGIN = 3
_RADIUS = 4 # = button border-radius (modern_dark.qss)
_MARK = QColor(183, 28, 28, 150) # THEME.accent_primary at ~60% alpha
# Neutral, not the triage red: red already means "you marked this" and "this failed".
# What a frame is built from is a fact about the asset, not a state the user set.
_COMPOSITE_CHIP = QColor(20, 20, 20, 190)
_COMPOSITE_RING = QColor(255, 255, 255, 90)
_COMPOSITE_GLYPH = QColor(255, 255, 255, 235)

def _draw_mark_badge(self, painter: QPainter, img_rect: QRect, check: bool) -> None:
r = 9
Expand All @@ -100,9 +106,40 @@ def _draw_failed_badge(self, painter: QPainter, img_rect: QRect) -> None:
painter.drawLine(cx, cy - 4, cx, cy + 1)
painter.drawPoint(cx, cy + 4)

def _draw_composite_badge(self, painter: QPainter, img_rect: QRect, kind: str, half: int) -> None:
"""Bottom-left mark: this frame was assembled from more than one file.

One glyph per kind, so a merge is told from a stitch without opening the menu.
The chip carries a faint ring because a flat dark disc vanishes on a dense frame."""
r = 9
cx, cy = img_rect.left() + r + 4, img_rect.bottom() - r - 4
painter.setPen(QPen(self._COMPOSITE_RING, 1))
painter.setBrush(self._COMPOSITE_CHIP)
painter.drawEllipse(QRect(cx - r, cy - r, 2 * r, 2 * r))
painter.setBrush(Qt.BrushStyle.NoBrush)
painter.setPen(QPen(self._COMPOSITE_GLYPH, 1.5))
if kind == "stitch": # two overlapping panes — a divided box reads as the half glyph
painter.drawRect(QRect(cx - 6, cy - 5, 8, 7))
front = QRect(cx - 2, cy - 2, 8, 7)
painter.fillRect(front, self._COMPOSITE_CHIP)
painter.drawRect(front)
elif kind == "hdr": # a bracket: stacked exposures
for dy, width in ((-3, 11), (0, 8), (3, 5)):
painter.drawLine(cx - 5, cy + dy, cx - 5 + width, cy + dy)
elif kind == "rgb": # the three narrowband exposures
painter.setPen(Qt.PenStyle.NoPen)
for dx, colour in ((-4, THEME.channel_red), (0, THEME.channel_green), (4, THEME.channel_blue)):
painter.setBrush(QColor(colour))
painter.drawEllipse(QRect(cx + dx - 2, cy - 2, 4, 4))
painter.setBrush(Qt.BrushStyle.NoBrush)
elif kind == "half": # a split frame, this asset's own half filled
painter.drawRect(QRect(cx - 6, cy - 4, 12, 8))
painter.fillRect(QRect(cx - 5 if half == 1 else cx + 1, cy - 3, 5, 7), self._COMPOSITE_GLYPH)

def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex) -> None:
file_info = index.data(Qt.ItemDataRole.UserRole) or {}
failed = bool(file_info.get("decode_failed"))
kind = composite_kind(file_info)

icon = index.data(Qt.ItemDataRole.DecorationRole)
if icon is None or icon.isNull():
Expand All @@ -114,6 +151,8 @@ def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIn
painter.setBrush(QColor(20, 20, 20))
painter.drawRoundedRect(area, self._RADIUS, self._RADIUS)
self._draw_failed_badge(painter, area)
if kind:
self._draw_composite_badge(painter, area, kind, int(file_info.get("half") or 0))
painter.restore()
return
base = icon.pixmap(QSize(4096, 4096)) # largest available pixmap (~120px)
Expand Down Expand Up @@ -152,6 +191,8 @@ def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIn
self._draw_mark_badge(painter, img_rect, check=False)
elif keeper:
self._draw_mark_badge(painter, img_rect, check=True)
if kind:
self._draw_composite_badge(painter, img_rect, kind, int(file_info.get("half") or 0))
painter.setClipping(False)

if selected:
Expand Down
96 changes: 91 additions & 5 deletions tests/test_file_browser_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,12 @@
from unittest.mock import MagicMock, patch

import pytest
from PyQt6.QtCore import QPoint, QPointF, QPropertyAnimation, Qt
from PyQt6.QtGui import QWheelEvent
from PyQt6.QtWidgets import QAbstractItemView, QApplication, QDialog
from PyQt6.QtCore import QPoint, QPointF, QPropertyAnimation, QRect, Qt
from PyQt6.QtGui import QColor, QIcon, QImage, QPainter, QPixmap, QWheelEvent
from PyQt6.QtWidgets import QAbstractItemView, QApplication, QDialog, QStyleOptionViewItem

from negpy.desktop.session import DesktopSessionManager
from negpy.desktop.view.sidebar.files import THUMB_CELL_MAX, THUMB_CELL_MIN, FileBrowser
from negpy.desktop.session import DesktopSessionManager, composite_kind, composite_summary
from negpy.desktop.view.sidebar.files import THUMB_CELL_MAX, THUMB_CELL_MIN, FileBrowser, _ThumbnailDelegate
from negpy.desktop.view.styles.theme import THEME
from negpy.desktop.view.widgets.granular_settings_dialog import GranularSettingsDialog
from negpy.domain.models import WorkspaceConfig
Expand Down Expand Up @@ -524,3 +524,89 @@ def test_session_menu_clear_all_clears_every_frame(browser, session):
with patch("negpy.desktop.view.sidebar.files.confirm_unload", return_value=True):
browser._on_clear_all()
session.clear_files.assert_called_once()


# --- Composite badges -----------------------------------------------------


def _composite_assets() -> dict:
return {
"plain": {"name": "a.cr2", "path": "/tmp/a.cr2", "hash": "h"},
"stitch": {"name": "a+b (Stitch)", "path": "/tmp/a.cr2", "hash": "h#stitch", "stitch_paths": ("/tmp/b.cr2",)},
"hdr": {"name": "a +2 (HDR)", "path": "/tmp/a.cr2", "hash": "h#hdr", "hdr_paths": ("/tmp/b.cr2", "/tmp/c.cr2")},
"rgb": {"name": "a.cr2", "path": "/tmp/a.cr2", "hash": "h", "green_path": "/tmp/g.cr2", "blue_path": "/tmp/b.cr2"},
"half": {"name": "a [2]", "path": "/tmp/a.cr2", "hash": "h#2", "half": 2},
}


@pytest.mark.parametrize("key,kind", [("plain", ""), ("stitch", "stitch"), ("hdr", "hdr"), ("rgb", "rgb"), ("half", "half")])
def test_composite_kind_reads_the_asset_dict(key, kind):
assert composite_kind(_composite_assets()[key]) == kind


def test_stitch_of_triplets_reads_as_a_stitch():
"""A stitch built from triplets carries the primary part's green/blue pair too, so
a green_path-first test would badge it as a triplet."""
asset = {**_composite_assets()["stitch"], "green_path": "/tmp/g.cr2", "blue_path": "/tmp/b.cr2"}
assert composite_kind(asset) == "stitch"


def test_composite_summary_counts_every_source_frame():
assets = _composite_assets()
assert composite_summary(assets["stitch"]) == "Stitched composite of 2 frames"
assert composite_summary(assets["hdr"]) == "HDR merge of 3 exposures"
assert composite_summary(assets["rgb"]) == "RGB-scan triplet"
assert composite_summary(assets["half"]) == "Half-frame split (2 of 2)"
assert composite_summary(assets["plain"]) == ""


def test_tooltip_names_what_the_frame_is_built_from(session):
session.state.uploaded_files = [_composite_assets()["hdr"], _composite_assets()["plain"]]
session.asset_model.refresh()
model = session.asset_model
tips = [model.data(model.index(row, 0), Qt.ItemDataRole.ToolTipRole) for row in range(model.rowCount())]
merged = [t for t in tips if "HDR merge" in t]
assert merged == ["/tmp/a.cr2\nHDR merge of 3 exposures"]
assert tips.count("/tmp/a.cr2") == 1 # the plain frame keeps the path alone


def _render(asset: dict) -> QImage:
"""Paint one delegate cell onto a pixmap. paint() reads only index.data(), so a
stub index is enough."""
thumb = QPixmap(60, 40)
thumb.fill(QColor("#808080"))
index = MagicMock()
index.data.side_effect = lambda role: {
Qt.ItemDataRole.UserRole: asset,
Qt.ItemDataRole.DecorationRole: QIcon(thumb),
}.get(role)

canvas = QPixmap(120, 120)
canvas.fill(QColor("#000000"))
option = QStyleOptionViewItem()
option.rect = QRect(0, 0, 120, 120)
painter = QPainter(canvas)
_ThumbnailDelegate().paint(painter, option, index)
painter.end()
return canvas.toImage()


def _badge_corner(image: QImage) -> list:
"""The 18px badge box at the bottom-left of the image outline. A 60x40 thumbnail in
a 120px cell lands at (3, 22, 114, 76), so the chip spans roughly (7, 75)-(25, 93)."""
return [image.pixel(x, y) for y in range(73, 95) for x in range(5, 27)]


@pytest.mark.parametrize("key", ["stitch", "hdr", "rgb", "half"])
def test_composite_badge_is_painted_bottom_left(key, qapp):
assets = _composite_assets()
assert _badge_corner(_render(assets[key])) != _badge_corner(_render(assets["plain"]))


def test_each_composite_kind_draws_its_own_glyph(qapp):
"""The point of per-kind glyphs: a merge must not look like a stitch."""
assets = _composite_assets()
corners = [_badge_corner(_render(assets[k])) for k in ("stitch", "hdr", "rgb", "half")]
for i, a in enumerate(corners):
for b in corners[i + 1 :]:
assert a != b
Loading