Skip to content

Commit 4175567

Browse files
shenmintaoclaude
andcommitted
refactor!: 降噪回归解码链语义——原生分辨率一次,视图派生,缩放永不重算
用户架构裁定:降噪就是去马赛克后的一环,做一次就不该再被触碰; 分级只属于下游视图。撤销 proxy/view 两个临时分级: - _executor_denoise 重写:无论首个视图是什么,都对原生全分辨率源 计算一次(内存+磁盘双缓存,键含模型版本);代理视图拿降采样、 缩放 ROI 直接裁 full、导出用 full——零重算。 - 计算期间仅当切换到"另一张照片"时让路(progress 回调比较 pending path);同照片的调参请求排队等待,不再因中断反复重来。 - 新增跨会话磁盘缓存 denoise_disk_cache(fp16+zstd ≈150MB/42MP, LRU 上限 RAWALCHEMY_DENOISE_CACHE_GB 默认 20)——重开照片秒载入。 - SCUNet 42s 是每张照片一生只付一次的成本;FastDenoise 落地后同一 架构直接把这一次缩到 ~2.4s。 - 241 测试全过(新契约:原生一次+全视图派生、跨会话磁盘命中) Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
1 parent 1797fd5 commit 4175567

4 files changed

Lines changed: 211 additions & 182 deletions

File tree

Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
"""降噪结果磁盘缓存:全图降噪一次付费、跨会话免费。
2+
3+
键:RAW 路径 + 文件大小 + mtime + 降噪模型文件名(模型升级自动失效)。
4+
存储:线性工作空间 float16 + zstd(≈150MB/42MP 张),LRU 按 atime 逐出。
5+
配置:RAWALCHEMY_DENOISE_CACHE_DIR / RAWALCHEMY_DENOISE_CACHE_GB(默认 20)。
6+
"""
7+
8+
import hashlib
9+
import os
10+
import time
11+
from pathlib import Path
12+
from typing import Optional
13+
14+
import numpy as np
15+
import zstandard
16+
from loguru import logger
17+
18+
_MAGIC = b"RADC1\n"
19+
20+
21+
def _cache_dir() -> Path:
22+
d = os.environ.get("RAWALCHEMY_DENOISE_CACHE_DIR")
23+
p = Path(d) if d else Path.home() / ".rawalchemy" / "denoise_cache"
24+
p.mkdir(parents=True, exist_ok=True)
25+
return p
26+
27+
28+
def _limit_bytes() -> int:
29+
try:
30+
gb = float(os.environ.get("RAWALCHEMY_DENOISE_CACHE_GB", "20"))
31+
except ValueError:
32+
gb = 20.0
33+
return int(gb * (1 << 30))
34+
35+
36+
def _key(raw_path: str, model_tag: str) -> Optional[str]:
37+
try:
38+
st = os.stat(raw_path)
39+
except OSError:
40+
return None
41+
h = hashlib.sha1(
42+
f"{os.path.abspath(raw_path)}|{st.st_size}|{int(st.st_mtime)}|{model_tag}"
43+
.encode("utf-8", "replace")
44+
).hexdigest()
45+
return h
46+
47+
48+
def load(raw_path: str, model_tag: str) -> Optional[np.ndarray]:
49+
"""命中返回 (H, W, 3) float32 线性工作空间;未命中返回 None。"""
50+
k = _key(raw_path, model_tag)
51+
if k is None:
52+
return None
53+
f = _cache_dir() / f"{k}.radc"
54+
if not f.exists():
55+
return None
56+
try:
57+
t0 = time.time()
58+
blob = f.read_bytes()
59+
assert blob[:6] == _MAGIC
60+
h, w = int.from_bytes(blob[6:10], "little"), int.from_bytes(blob[10:14], "little")
61+
data = zstandard.ZstdDecompressor().decompress(blob[14:], max_output_size=h * w * 3 * 2)
62+
arr = np.frombuffer(data, np.float16).reshape(h, w, 3).astype(np.float32)
63+
os.utime(f) # LRU: 命中刷新 atime/mtime
64+
logger.info(f"[DenoiseCache] hit {os.path.basename(raw_path)} "
65+
f"({time.time() - t0:.2f}s load)")
66+
return arr
67+
except Exception as e:
68+
logger.warning(f"[DenoiseCache] corrupt entry dropped: {e}")
69+
try:
70+
f.unlink()
71+
except OSError:
72+
pass
73+
return None
74+
75+
76+
def save(raw_path: str, model_tag: str, denoised: np.ndarray) -> None:
77+
k = _key(raw_path, model_tag)
78+
if k is None:
79+
return
80+
try:
81+
t0 = time.time()
82+
h, w = denoised.shape[:2]
83+
payload = zstandard.ZstdCompressor(level=1).compress(
84+
np.ascontiguousarray(denoised, np.float32).astype(np.float16).tobytes())
85+
blob = _MAGIC + h.to_bytes(4, "little") + w.to_bytes(4, "little") + payload
86+
f = _cache_dir() / f"{k}.radc"
87+
tmp = f.with_suffix(".tmp")
88+
tmp.write_bytes(blob)
89+
tmp.replace(f)
90+
logger.info(f"[DenoiseCache] saved {os.path.basename(raw_path)} "
91+
f"({len(blob) / 1e6:.0f}MB, {time.time() - t0:.2f}s)")
92+
_evict()
93+
except Exception as e:
94+
logger.warning(f"[DenoiseCache] save failed: {e}")
95+
96+
97+
def _evict() -> None:
98+
limit = _limit_bytes()
99+
d = _cache_dir()
100+
files = sorted(d.glob("*.radc"), key=lambda f: f.stat().st_mtime)
101+
total = sum(f.stat().st_size for f in files)
102+
while total > limit and files:
103+
f = files.pop(0)
104+
try:
105+
total -= f.stat().st_size
106+
f.unlink()
107+
logger.info(f"[DenoiseCache] evicted {f.name}")
108+
except OSError:
109+
break

src/raw_alchemy/pipeline/ops.py

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -47,10 +47,7 @@ def build_op_list(params: ProcessorParams) -> list[Op]:
4747
working_space = config.WORKING_SPACE
4848

4949
if params.get("denoise_enabled", False):
50-
# tier: 'proxy' while only the fast proxy-resolution denoise exists,
51-
# 'full' once the full-res result is cached (worker injects it) —
52-
# keying prefix/output caches so tier upgrades re-render cleanly.
53-
ops.append(Op("denoise", (params.get("_denoise_tier"),)))
50+
ops.append(Op("denoise", ()))
5451

5552
if params.get("lens_correct", False):
5653
ops.append(Op("lens_correct", (_as_hashable(params.get("custom_db_path")),)))

src/raw_alchemy/workers/image_processor.py

Lines changed: 34 additions & 131 deletions
Original file line numberDiff line numberDiff line change
@@ -129,12 +129,6 @@ def __init__(self):
129129
self.cached_denoise_full = None
130130
self.cached_denoise_proxy = None
131131
self.last_denoise_key = None
132-
# Fast tier: proxy-resolution denoise (~3s) shown while the full-res
133-
# pass (~40s, abortable, idle-scheduled) hasn't landed yet.
134-
self.last_denoise_proxy_key = None
135-
# View tier: per-ROI denoise results (zoomed views before the full
136-
# pass lands). Keyed by (path, roi_rect, shape); tiny LRU.
137-
self._denoise_view_cache: dict = {}
138132

139133
self._should_stop = False
140134
self._gpu_uint8 = None # Pre-allocated pooled uint8 output buffer
@@ -1045,130 +1039,70 @@ def _trim_executor_caches(self, budget_bytes: Optional[int] = None):
10451039
full.trim(remaining)
10461040

10471041
def _executor_denoise(self, src: np.ndarray) -> np.ndarray:
1042+
"""降噪 = 解码链的一环(去马赛克后):原生分辨率只算一次,
1043+
内存+磁盘双缓存;所有视图(代理/任意缩放 ROI/导出)从结果派生,
1044+
缩放拖动永不触发重算。计算期间仅当切换到其他照片时让路。"""
10481045
path = self._executor_path
10491046
if path is None:
10501047
return src
10511048

10521049
denoise_cache_key = (path, 'denoise')
1053-
1054-
# Proxy-mode preview. With the full result cached, serve its
1055-
# downscale; otherwise denoise the proxy itself (~3s) as the fast
1056-
# tier — the full pass runs later (idle refine / export) and its
1057-
# arrival flips the op tier, re-rendering from the exact result.
1058-
if self._executor_using_proxy:
1059-
if (
1060-
denoise_cache_key == self.last_denoise_key
1061-
and self.cached_denoise_full is not None
1062-
):
1063-
if self.cached_denoise_proxy is None:
1064-
self.cached_denoise_proxy = self._make_proxy(self.cached_denoise_full)
1065-
if self.cached_denoise_proxy is not None:
1066-
return np.ascontiguousarray(self.cached_denoise_proxy)
1067-
proxy_key = (path, 'denoise-proxy')
1068-
if (
1069-
proxy_key == self.last_denoise_proxy_key
1070-
and self.cached_denoise_proxy is not None
1071-
):
1072-
return np.ascontiguousarray(self.cached_denoise_proxy)
1073-
try:
1074-
self.denoise_started.emit()
1075-
logger.info("[Worker] SCUNet proxy-tier denoise (fast preview)...")
1076-
1077-
def progress_cb(cur, total):
1078-
self.denoise_progress.emit(cur, total)
1079-
1080-
denoised = denoise_rgb_linear(src, progress_callback=progress_cb)
1081-
self.cached_denoise_proxy = denoised
1082-
self.last_denoise_proxy_key = proxy_key
1083-
self.denoise_finished.emit()
1084-
return np.ascontiguousarray(denoised)
1085-
except Exception as e:
1086-
logger.error(f"[Worker] Proxy denoise failed: {e}")
1087-
self.denoise_finished.emit()
1088-
return src
1089-
finally:
1090-
denoise_clear_session()
1091-
1092-
if self._executor_params.get('_denoise_tier') == 'view':
1093-
rect = self._executor_params.get('_preview_roi')
1094-
vkey = (path, tuple(rect) if rect else None, src.shape)
1095-
cached = self._denoise_view_cache.get(vkey)
1050+
missing = (denoise_cache_key != self.last_denoise_key
1051+
or self.cached_denoise_full is None)
1052+
if missing:
1053+
from raw_alchemy.pipeline import denoise_disk_cache
1054+
from raw_alchemy.onnx.rgb_denoiser import MODEL_FILE as _DN_TAG
1055+
cached = denoise_disk_cache.load(path, _DN_TAG)
10961056
if cached is not None:
1097-
return np.ascontiguousarray(cached)
1098-
try:
1099-
self.denoise_started.emit()
1100-
logger.info("[Worker] SCUNet view-tier denoise (ROI)...")
1101-
abortable = bool(self._executor_params.get('_denoise_abortable'))
1102-
1103-
def progress_cb(cur, total):
1104-
self.denoise_progress.emit(cur, total)
1105-
if abortable:
1106-
with self.lock:
1107-
superseded = self.pending_request is not None
1108-
if superseded:
1109-
raise PipelineAborted(
1110-
f"view denoise superseded at tile {cur}/{total}"
1111-
)
1112-
1113-
denoised = denoise_rgb_linear(src, progress_callback=progress_cb)
1114-
if len(self._denoise_view_cache) >= 4:
1115-
self._denoise_view_cache.pop(next(iter(self._denoise_view_cache)))
1116-
self._denoise_view_cache[vkey] = denoised
1117-
self.denoise_finished.emit()
1118-
return np.ascontiguousarray(denoised)
1119-
except PipelineAborted:
1120-
self.denoise_finished.emit()
1121-
raise
1122-
except Exception as e:
1123-
logger.error(f"[Worker] View denoise failed: {e}")
1124-
self.denoise_finished.emit()
1125-
return src
1126-
finally:
1127-
denoise_clear_session()
1057+
self.cached_denoise_original = self.cpu_linear
1058+
self.cached_denoise_full = cached
1059+
self.cached_denoise_proxy = None
1060+
self.last_denoise_key = denoise_cache_key
1061+
missing = False
11281062

1129-
if denoise_cache_key != self.last_denoise_key or self.cached_denoise_full is None:
1063+
if missing:
1064+
# 无论当前视图是代理还是 ROI,都对原生全分辨率源计算
1065+
source = self.cpu_linear if self.cpu_linear is not None else src
11301066
try:
11311067
self.denoise_started.emit()
1132-
logger.info("[Worker] SCUNet RGB denoise (post-demosaic)...")
1133-
1134-
abortable = bool(self._executor_params.get('_denoise_abortable'))
1068+
logger.info("[Worker] SCUNet RGB denoise (native, once)...")
11351069

11361070
def progress_cb(cur, total):
11371071
self.denoise_progress.emit(cur, total)
1138-
if abortable:
1139-
with self.lock:
1140-
superseded = self.pending_request is not None
1141-
if superseded:
1142-
raise PipelineAborted(
1143-
f"full denoise superseded at tile {cur}/{total}"
1144-
)
1145-
1146-
denoised = denoise_rgb_linear(
1147-
src,
1148-
progress_callback=progress_cb,
1149-
)
1072+
with self.lock:
1073+
pr = self.pending_request
1074+
if pr is not None and pr.path != path:
1075+
raise PipelineAborted(
1076+
f"denoise superseded by another photo at {cur}/{total}")
11501077

1078+
denoised = denoise_rgb_linear(source, progress_callback=progress_cb)
11511079
self.cached_denoise_original = self.cpu_linear
11521080
self.cached_denoise_full = denoised
11531081
self.cached_denoise_proxy = None
11541082
self.last_denoise_key = denoise_cache_key
1083+
denoise_disk_cache.save(path, _DN_TAG, denoised)
11551084
self.denoise_finished.emit()
11561085
except PipelineAborted:
11571086
self.denoise_finished.emit()
11581087
raise
11591088
except Exception as e:
1160-
logger.error(f"[Worker] Denoising failed: {e}")
1089+
logger.error(f"[Worker] Denoising failed: {type(e).__name__}: {e}")
11611090
self.denoise_finished.emit()
11621091
self.cached_denoise_original = None
11631092
self.cached_denoise_full = None
11641093
self.cached_denoise_proxy = None
11651094
self.last_denoise_key = None
11661095
return src
11671096
finally:
1168-
# Always drop the ONNX session: on DirectML it pins hundreds
1169-
# of MB of VRAM that the interactive preview renderer needs.
11701097
denoise_clear_session()
11711098

1099+
if self._executor_using_proxy:
1100+
if self.cached_denoise_proxy is None:
1101+
self.cached_denoise_proxy = self._make_proxy(self.cached_denoise_full)
1102+
if self.cached_denoise_proxy is not None:
1103+
return np.ascontiguousarray(self.cached_denoise_proxy)
1104+
return np.ascontiguousarray(self.cached_denoise_full)
1105+
11721106
denoised = np.ascontiguousarray(self.cached_denoise_full)
11731107
if not self._executor_params.get('lens_correct', False):
11741108
self.cpu_corrected = denoised
@@ -1296,16 +1230,11 @@ def _executor_auto_gain(self, _metering_img: np.ndarray, metering_mode: str) ->
12961230
def _prepare_executor_source_state(self, params: ProcessorParams):
12971231
self._executor_corrected_source = None
12981232
denoise_enabled = params.get('denoise_enabled', False)
1299-
if not denoise_enabled and (
1300-
self.last_denoise_key is not None
1301-
or self.last_denoise_proxy_key is not None
1302-
):
1233+
if not denoise_enabled and self.last_denoise_key is not None:
13031234
self.cached_denoise_original = None
13041235
self.cached_denoise_full = None
13051236
self.cached_denoise_proxy = None
13061237
self.last_denoise_key = None
1307-
self.last_denoise_proxy_key = None
1308-
self._denoise_view_cache.clear()
13091238

13101239
if not params.get('lens_correct', False):
13111240
if denoise_enabled and self.cached_denoise_full is not None:
@@ -1467,25 +1396,6 @@ def _do_process(self, request: ProcessRequest):
14671396
# zoom>fit ROI rendering (T7.5): resolve the visible-region crop
14681397
# before the output key so the view key covers the ROI rect.
14691398
roi_info = None if use_proxy else self._compute_preview_roi(params)
1470-
if params.get('denoise_enabled', False):
1471-
full_ready = (
1472-
(request.path, 'denoise') == self.last_denoise_key
1473-
and self.cached_denoise_full is not None
1474-
)
1475-
# tier(与 DPI 挡位同一哲学:屏幕需要多少就处理多少):
1476-
# proxy = fit 视图对 3MP 代理降噪(~3s)
1477-
# view = 放大视图只对 DPI 挡位后的 ROI 降噪(~6-19s,带缓存)
1478-
# full = 精确结果(空闲精化产出/导出)
1479-
if full_ready:
1480-
params['_denoise_tier'] = 'full'
1481-
elif use_proxy:
1482-
params['_denoise_tier'] = 'proxy'
1483-
elif roi_info is not None:
1484-
params['_denoise_tier'] = 'view'
1485-
else:
1486-
params['_denoise_tier'] = 'full'
1487-
# 预览请求中的降噪按 tile 让路给新交互(空闲精化重试)
1488-
params['_denoise_abortable'] = True
14891399
if roi_info is not None:
14901400
params['_preview_roi'] = roi_info[0]
14911401
params['_roi_full_size'] = roi_info[1]
@@ -1536,13 +1446,6 @@ def _do_process(self, request: ProcessRequest):
15361446
_tw, _th = self._make_roi_target_size(_rw, _rh, _fw, _fh, params)
15371447
_roi_out = (_tw, _th) if (_tw < _rw * 0.95 or _th < _rh * 0.95) else None
15381448
ops = self._insert_roi_op(ops, roi_info[0], _roi_out)
1539-
if params.get('_denoise_tier') == 'view':
1540-
di = next((k for k, o in enumerate(ops) if o.name == 'denoise'), None)
1541-
ri = next((k for k, o in enumerate(ops) if o.name == 'roi'), None)
1542-
if di is not None and ri is not None and di < ri:
1543-
d_op = ops.pop(di)
1544-
ri = next(k for k, o in enumerate(ops) if o.name == 'roi')
1545-
ops.insert(ri + 1, d_op)
15461449
executor_source = self._select_executor_source(use_proxy)
15471450
executor = self._get_preview_executor()
15481451
# Cooperative cancellation (T7.3): a newer user request aborts

0 commit comments

Comments
 (0)