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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
0.40.0
0.41.0
4 changes: 4 additions & 0 deletions docs/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
# Change Log

## 0.41.0

- New: **Stitch multi-shot scans** — select overlapping shots of one frame (e.g. a 6×6 scanned in two halves) on the contact sheet and pick **Stitch selected frames**. Alignment, exposure matching and blending happen on the linear scan data before conversion, so the result develops like a single raw. No new file is written: the composite edits and exports like any frame, and **Unstitch** restores the parts. IR dust data is kept when all parts have it.

## 0.40.0

- New: **Sharpening rebuilt — Radius, Masking and a new Deconvolution mode** — the Sharpen controls, now in their own section, gain a **Radius** slider (how fine or broad the sharpened detail is), a **Masking** slider (holds sharpening off flat areas like skies and skin so grain and noise stay quiet), and a **Method** dropdown: **Unsharp Mask** (the classic, now with halo suppression on high-contrast edges) or **Deconvolution** (Richardson-Lucy, which models the lens/scan blur in linear light to pull back genuinely soft detail). Existing edits keep their look on Unsharp Mask.
Expand Down
104 changes: 100 additions & 4 deletions negpy/desktop/controller.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@

from negpy.desktop.converters import ImageConverter
from negpy.desktop.render_memo import RenderMemo
from negpy.desktop.session import AppState, DesktopSessionManager, ToolMode, resolve_asset_rgbscan
from negpy.desktop.session import AppState, DesktopSessionManager, ToolMode, resolve_asset_rgbscan, resolve_asset_stitch
from negpy.desktop.workers.export import ExportTask, ExportWorker, find_export_conflicts
from negpy.desktop.workers.render import (
AssetDiscoveryTask,
Expand All @@ -29,6 +29,8 @@
ThumbnailWorker,
)
from negpy.desktop.workers.scan_worker import BatchRequest, RollPreviewRequest, ScanRequest, ScanWorker
from negpy.desktop.workers.stitch import StitchTask, StitchWorker
from negpy.features.stitch.models import stitch_hash, stitch_name
from negpy.desktop.workers.capture_worker import (
CalibrationRequest,
CaptureRequest,
Expand Down Expand Up @@ -127,6 +129,7 @@ class _DiscoveryRequest:
reselect_path: Optional[str]
rgb_scan: bool
half_frame: bool
restore_stitches: Optional[dict] = None


def baseline_compare_config(config: WorkspaceConfig) -> WorkspaceConfig:
Expand Down Expand Up @@ -176,6 +179,7 @@ class AppController(QObject):
crop_guide_changed = pyqtSignal()
dust_overlay_changed = pyqtSignal()
asset_discovery_requested = pyqtSignal(AssetDiscoveryTask)
stitch_requested = pyqtSignal(object)
thumbnail_requested = pyqtSignal(list)
thumbnail_update_requested = pyqtSignal(ThumbnailUpdateTask)
tool_sync_requested = pyqtSignal()
Expand Down Expand Up @@ -281,6 +285,9 @@ def __init__(self, session_manager: DesktopSessionManager):
self.export_thread = QThread()
self.export_worker = ExportWorker()
self.export_worker.moveToThread(self.export_thread)
# Shares the export thread: the batch lane serializes them anyway.
self.stitch_worker = StitchWorker()
self.stitch_worker.moveToThread(self.export_thread)
self.export_thread.start()

self.thumb_thread = QThread()
Expand Down Expand Up @@ -445,6 +452,12 @@ def _connect_signals(self) -> None:
self.export_worker.error.connect(self._on_render_error)
self.export_worker.error.connect(self._on_export_task_error)

self.stitch_requested.connect(self.stitch_worker.run)
self.stitch_worker.progress.connect(self._on_batch_progress)
self.stitch_worker.registered.connect(self._on_stitch_registered)
self.stitch_worker.cancelled.connect(self._on_stitch_cancelled)
self.stitch_worker.error.connect(self._on_stitch_error)

self.thumbnail_requested.connect(self.thumb_worker.generate)
self.thumb_worker.progress.connect(self._on_thumbnail_progress)
self.thumbnail_update_requested.connect(self.thumb_worker.update_rendered)
Expand Down Expand Up @@ -633,6 +646,8 @@ def abort_active_batch(self) -> None:
elif self._active_batch == "autocrop":
self._autocrop_cancel_requested = True
self.batch_autocrop_worker.cancel(self._autocrop_batch_token)
elif self._active_batch == "stitch":
self.stitch_worker.cancel()

def saved_session_paths(self) -> List[str]:
"""Returns last session's file paths that still exist on disk."""
Expand All @@ -647,7 +662,8 @@ def restore_session(self) -> None:
active = self.session.repo.get_global_setting("session_active_path")
self._pending_scanned_file = active if active in paths else paths[0]
triplets = self.session.repo.get_global_setting("session_triplets", {}) or {}
self.request_asset_discovery(paths, auto_open=True, restore_triplets=triplets)
stitches = self.session.repo.get_global_setting("session_stitches", {}) or {}
self.request_asset_discovery(paths, auto_open=True, restore_triplets=triplets, restore_stitches=stitches)

def request_asset_discovery(
self,
Expand All @@ -656,6 +672,7 @@ def request_asset_discovery(
restore_triplets: Optional[dict] = None,
replace_existing: bool = False,
reselect_path: Optional[str] = None,
restore_stitches: Optional[dict] = None,
) -> None:
"""
Starts asynchronous discovery of supported assets.
Expand All @@ -673,6 +690,7 @@ def request_asset_discovery(
reselect_path=reselect_path,
rgb_scan=bool(self.session.repo.get_global_setting("rgbscan_mode", False)),
half_frame=bool(self.session.repo.get_global_setting("half_frame_mode", False)),
restore_stitches=restore_stitches,
)
if self._discovery_running:
self._pending_asset_discoveries.append(request)
Expand Down Expand Up @@ -705,6 +723,7 @@ def _start_asset_discovery(self, request: _DiscoveryRequest) -> None:
rgb_scan=request.rgb_scan,
restore_triplets=request.restore_triplets,
half_frame=request.half_frame,
restore_stitches=request.restore_stitches,
)
self.asset_discovery_requested.emit(task)

Expand Down Expand Up @@ -913,6 +932,8 @@ def load_file(self, file_path: str, preserve_zoom: bool = False, force_detect: b
self.state.is_dirty = True

rgbscan = self.state.config.rgbscan
stitch = self.state.config.stitch
flatfield = self.state.config.flatfield
self.preview_load_requested.emit(
PreviewLoadTask(
file_path=file_path,
Expand All @@ -931,6 +952,11 @@ def load_file(self, file_path: str, preserve_zoom: bool = False, force_detect: b
green_path=rgbscan.green_path if rgbscan.enabled else "",
blue_path=rgbscan.blue_path if rgbscan.enabled else "",
align=rgbscan.align,
stitch_paths=stitch.stitch_paths if stitch.stitch_enabled else (),
stitch_transforms=stitch.stitch_transforms if stitch.stitch_enabled else (),
stitch_canvas=stitch.stitch_canvas,
stitch_sizes=stitch.stitch_sizes,
flatfield_path=flatfield.reference_path if (stitch.stitch_enabled and flatfield.apply) else "",
)
)

Expand Down Expand Up @@ -1280,7 +1306,7 @@ def apply_auto_crop(self) -> None:
def _config_for_autocrop_asset(self, asset: dict) -> WorkspaceConfig:
"""Resolve per-asset settings, including unsaved edits on the active frame."""
if asset.get("hash") == self.state.current_file_hash:
return resolve_asset_rgbscan(self.state.config, asset)
return resolve_asset_stitch(resolve_asset_rgbscan(self.state.config, asset), asset)
return self.session.config_for_asset(asset)

def request_batch_auto_crop(self) -> None:
Expand Down Expand Up @@ -2068,6 +2094,76 @@ def _on_scan_batch_finished(self, paths: list) -> None:
self._pending_scanned_file = paths[-1]
self.request_asset_discovery(list(paths))

# ── Stitch (multi-part scan composite) ─────────────────────────────

def request_stitch_selected(self) -> None:
"""Register the selected frames into one stitched composite asset."""
if self._batch_busy("Stitch"):
return
files = [self.state.uploaded_files[i] for i in sorted(set(self.state.selected_indices)) if 0 <= i < len(self.state.uploaded_files)]
by_path = {f["path"]: f for f in files} # half-frame assets share a path
ordered = sorted(by_path.values(), key=lambda f: os.path.basename(f["path"]).lower())
if len(ordered) < 2:
self.set_status("Select two or more frames to stitch", 4000)
return
if any(f.get("green_path") or f.get("stitch_paths") for f in ordered):
self.set_status("Stitching RGB-scan or already-stitched frames is not supported", 4000)
return
if self._begin_batch("stitch", "Stitching frames", abortable=True) is None:
return
self.stitch_requested.emit(
StitchTask(
files=tuple(dict(f) for f in ordered),
params_by_path={f["path"]: self._batch_params_for(f) for f in ordered},
)
)

def _on_stitch_registered(self, payload: dict) -> None:
self._end_batch("stitch")
files = payload["files"]
part_paths = [f["path"] for f in files]
composite = {
"name": stitch_name(part_paths),
"path": part_paths[0],
"hash": stitch_hash([f["hash"] for f in files]),
"stitch_paths": tuple(part_paths[1:]),
"stitch_transforms": payload["transforms"],
"stitch_canvas": payload["canvas"],
"stitch_sizes": payload["sizes"],
}
wanted = set(part_paths)
indices = [i for i, f in enumerate(self.state.uploaded_files) if f["path"] in wanted]
self.session.apply_stitch(indices, composite)
self.set_status(f"Stitched {len(files)} frames", 4000)
# The composite bypasses asset discovery, so nothing else queues its thumbnail.
self.generate_missing_thumbnails()

def _on_stitch_cancelled(self) -> None:
self._on_batch_cancelled("stitch")

def _on_stitch_error(self, message: str) -> None:
self._end_batch("stitch")
self.set_status(message, 6000)

def request_unstitch(self) -> None:
"""Dissolve the active stitched composite back into its part frames.

Part edits restore from the DB by content hash; the composite's edits stay
keyed under its stitch hash for a future re-stitch of the same parts."""
idx = self.state.selected_file_idx
if not (0 <= idx < len(self.state.uploaded_files)):
return
asset = self.state.uploaded_files[idx]
parts = asset.get("stitch_paths")
if not parts:
return
paths = [asset["path"], *parts]
self.state.uploaded_files.pop(idx)
self.session.state.thumbnails.pop(asset["name"], None)
self.session.asset_model.refresh()
self._pending_scanned_file = paths[0]
self.request_asset_discovery(paths)

def _select_file_by_path(self, path: str) -> bool:
"""Find a file by path in uploaded_files and select it."""
for i, f_info in enumerate(self.session.state.uploaded_files):
Expand Down Expand Up @@ -2412,7 +2508,7 @@ def _batch_params_for(self, f: dict) -> WorkspaceConfig:
config), with its own RGB-scan green/blue re-injected from the asset dict — the
same authoritative source individual export gets via select_file."""
params = self.session.repo.load_file_settings(f["hash"]) or self.state.config
return resolve_asset_rgbscan(params, f)
return resolve_asset_stitch(resolve_asset_rgbscan(params, f), f)

def _tasks_for_file(
self,
Expand Down
58 changes: 57 additions & 1 deletion negpy/desktop/session.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from negpy.domain.models import ExportPreset, WorkspaceConfig
from negpy.features.exposure.models import apply_targets
from negpy.features.rgbscan.models import RgbScanConfig
from negpy.features.stitch.models import StitchConfig
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
Expand Down Expand Up @@ -421,6 +422,27 @@ def resolve_asset_rgbscan(params: WorkspaceConfig, asset: dict) -> WorkspaceConf
return replace(params, rgbscan=RgbScanConfig())


def resolve_asset_stitch(params: WorkspaceConfig, asset: dict) -> WorkspaceConfig:
"""Overlay a composite's stored registration (from the asset dict — the authoritative
source) onto its params. A non-stitch asset gets stitch reset so a plain frame never
inherits a leaked composite config. Session/JSON round-trips lists — coerce to tuples
so the frozen config stays hashable."""
paths = asset.get("stitch_paths")
if paths:
canvas = asset.get("stitch_canvas") or (0, 0)
return replace(
params,
stitch=StitchConfig(
stitch_enabled=True,
stitch_paths=tuple(paths),
stitch_transforms=tuple(tuple(float(v) for v in t) for t in asset.get("stitch_transforms") or ()),
stitch_canvas=(int(canvas[0]), int(canvas[1])),
stitch_sizes=tuple((int(s[0]), int(s[1])) for s in asset.get("stitch_sizes") or ()),
),
)
return replace(params, stitch=StitchConfig())


class DesktopSessionManager(QObject):
"""
Manages application state, file list, and configuration persistence.
Expand Down Expand Up @@ -776,7 +798,7 @@ def _hydrate_asset_config(self, asset: dict) -> tuple[WorkspaceConfig, bool]:
config = self._apply_sticky_settings(saved_config, only_global=True)
else:
config = self._apply_sticky_settings(WorkspaceConfig(), only_global=False)
return resolve_asset_rgbscan(config, asset), is_new
return resolve_asset_stitch(resolve_asset_rgbscan(config, asset), asset), is_new

def config_for_asset(self, asset: dict) -> WorkspaceConfig:
"""Return an asset's hydrated config without changing the active session state.
Expand Down Expand Up @@ -1101,6 +1123,20 @@ def _persist_session(self) -> None:
if f.get("green_path") and f.get("blue_path")
}
self.repo.save_global_setting("session_triplets", triplets)
# Stitch composites keep their parts + registration here so restore can rebuild
# the merged asset without re-running SIFT (re-discovery sees only the primary).
stitches = {
f["path"]: {
"paths": list(f["stitch_paths"]),
"transforms": [list(t) for t in f["stitch_transforms"]],
"canvas": list(f["stitch_canvas"]),
"sizes": [list(s) for s in f["stitch_sizes"]],
"hash": f["hash"],
}
for f in self.state.uploaded_files
if f.get("stitch_paths")
}
self.repo.save_global_setting("session_stitches", stitches)

def add_files(self, file_paths: List[str], validated_info: Optional[List[Dict]] = None) -> None:
"""
Expand Down Expand Up @@ -1157,6 +1193,26 @@ def add_files(self, file_paths: List[str], validated_info: Optional[List[Dict]]
self.files_changed.emit()
self._persist_session()

def apply_stitch(self, indices: List[int], composite: dict) -> None:
"""Replace the part assets with their stitched composite (inserted at the first
part's position), then open it. Part edits stay in the DB under their content
hashes, so an unstitch restores them intact."""
valid = sorted({i for i in indices if 0 <= i < len(self.state.uploaded_files)})
if not valid:
return
pos = valid[0]
for i in reversed(valid):
removed = self.state.uploaded_files.pop(i)
self.state.thumbnails.pop(removed["name"], None)
marks = self.repo.load_file_marks()
m = marks.get(composite["hash"])
composite = {**composite, "keeper": m == "keeper", "excluded": m == "excluded"}
self.state.uploaded_files.insert(pos, composite)
self.asset_model.refresh()
self.files_changed.emit()
self._persist_session()
self.select_file(pos)

def set_triplet(self, index: int, red_path: str, green_path: str, blue_path: str, align: bool = True) -> None:
"""Reassign the R/G/B exposures of an RGB-scan asset, then reload it."""
import os
Expand Down
8 changes: 7 additions & 1 deletion negpy/desktop/view/sidebar/files.py
Original file line number Diff line number Diff line change
Expand Up @@ -764,9 +764,15 @@ def _build_context_menu(self) -> QMenu:
act_reject.triggered.connect(lambda: self.session.toggle_mark("excluded"))
menu.addSeparator()
menu.addAction("Apply settings…").triggered.connect(self._open_apply_dialog)
if not multi:
if multi:
menu.addSeparator()
menu.addAction("Stitch selected frames").triggered.connect(lambda: self.controller.request_stitch_selected())
else:
menu.addSeparator()
menu.addAction("Edit RGB Triplet…").triggered.connect(self._on_edit_triplet)
active = state.uploaded_files[state.selected_file_idx] if 0 <= state.selected_file_idx < len(state.uploaded_files) else {}
if active.get("stitch_paths"):
menu.addAction("Unstitch").triggered.connect(lambda: self.controller.request_unstitch())
menu.addSeparator()
unload_label = "Unload Selected" if multi else "Unload"
menu.addAction(unload_label).triggered.connect(self._on_remove_from_menu)
Expand Down
Loading
Loading