From d6a8723ed53ae3c9769c0d39ec1ecbb462beb0a4 Mon Sep 17 00:00:00 2001 From: thetalkingdrum <254868117+thetalkingdrum@users.noreply.github.com> Date: Sun, 1 Nov 2026 17:37:41 +0100 Subject: [PATCH] feat(fade): Gray Range sets Red Survival like Midtone Gray Range left Red Survival as it was, so on a badly faded slide it set green and blue survivals near 5 with red at 1.0 and blew the frame out. Both methods now set Red Survival to 1/max(green, blue, 1), so the best-kept layer keeps its density. A mild fade with both ratios at or below 1 keeps Red Survival at 1.0. --- docs/PIPELINE.md | 4 ++-- docs/USER_GUIDE.md | 2 +- negpy/desktop/view/sidebar/sensor.py | 14 ++++---------- negpy/features/process/fade.py | 6 +++--- negpy/translations/USER_GUIDE.de.md | 4 ++-- negpy/translations/USER_GUIDE.en.md | 2 +- negpy/translations/USER_GUIDE.ja.md | 4 ++-- negpy/translations/USER_GUIDE.ko.md | 4 ++-- negpy/translations/USER_GUIDE.zh_CN.md | 4 ++-- negpy/translations/de.json | 5 ++--- negpy/translations/ja.json | 5 ++--- negpy/translations/ko.json | 5 ++--- negpy/translations/zh_CN.json | 5 ++--- tests/test_fade_estimation.py | 8 ++++---- 14 files changed, 31 insertions(+), 41 deletions(-) diff --git a/docs/PIPELINE.md b/docs/PIPELINE.md index 70030e22e..d82d040ad 100644 --- a/docs/PIPELINE.md +++ b/docs/PIPELINE.md @@ -60,9 +60,9 @@ Pixel values are linear transmittance. The source corrections run in that domain * **Stretch**: each channel stretches independently to $[0, 1]$, which removes the orange mask and base tints. The result is not clamped; the print curve's knees roll off tones outside the bounds. * **Per-frame metering**: an exposure anchor (Auto Density) and a textural range ($P_{10}$ to $P_{90}$ luminance, Auto Grade), read on textured cells only. Sectors of 2×2 `activity_block` cells vote when their block means span more than `activity_gate_density` ($0.05$ log D; Boyack & Juenger, US 5,724,456); below `activity_min_fraction` passing sectors, every cell votes. * **Channel unmix / crosstalk** (`crosstalk_strength` / `crosstalk_matrix`, docs/CROSSTALK.md): a 3×3 unmix of the negative log densities before bounds and stretch, where dye absorptions, light spectrum and CFA are linear, so a profile describes a scanning setup. It is blended with identity by strength and row-normalized, so grays stay. Every meter reads the unmixed film, so a change needs a new Roll Analysis. -* **Fade restoration** (`fade_ratio_r` / `fade_ratio_g` / `fade_ratio_b`, slides only: TRANSFER and POSITIVE): composes a diagonal restoration matrix $F^{-1}$ after the crosstalk unmix, $M = F^{-1} M_{\text{unmix}}$ (`features/process/fade.py::unmix_with_fade`). It runs before Cast Removal, which then meters the restored film. $F = \mathrm{diag}(a_r,\, a_r a_g,\, a_r a_b)$ acts on densities. $a_g$ and $a_b$ are survival ratios to red, which a neutral can constrain. $a_r$ is red's own survival and is set by eye. $F$ is the identity at unit survival, and it is not row-normalized. +* **Fade restoration** (`fade_ratio_r` / `fade_ratio_g` / `fade_ratio_b`, slides only: TRANSFER and POSITIVE): composes a diagonal restoration matrix $F^{-1}$ after the crosstalk unmix, $M = F^{-1} M_{\text{unmix}}$ (`features/process/fade.py::unmix_with_fade`). It runs before Cast Removal, which then meters the restored film. $F = \mathrm{diag}(a_r,\, a_r a_g,\, a_r a_b)$ acts on densities. $a_g$ and $a_b$ are survival ratios to red, which a neutral can constrain. $a_r$ is red's own survival, which no neutral constrains. $F$ is the identity at unit survival, and it is not row-normalized. - Estimate (`features/process/fade.py`) runs on request, never in a render. It renders the frame on the CPU with $F$ at identity and White Anchor off, and reads the transparency meter grid (`transfer_grid`: bakes, geometry, crop, camera matrix and unmix). It meters the grays with `measure_neutral_axis_from_log` against the frame's own bounds: first with the foliage and sky sectors excluded, against bounds from each channel's extreme cells with decoded zeros dropped, at confidence `MIN_CONFIDENCE` ($0.2$) or more. Else it reads against bounds from the `ROBUST_EXTREME_PCT` ($0.1$) percentiles, with the sectors excluded, then not, at `ROBUST_MIN_CONFIDENCE` ($0.4$). MIDTONE reads the midtone densities and also sets $a_r$; SPREAD reads each channel's midtone-to-shadow spread. Ratios clamp to $[0.2, 5]$, and the hint shows the unclamped value. The hint also shows the other method's reading: a large gap is a cast that a fade does not explain. A near-monochrome slide meters as faded. + Estimate (`features/process/fade.py`) runs on request, never in a render. It renders the frame on the CPU with $F$ at identity and White Anchor off, and reads the transparency meter grid (`transfer_grid`: bakes, geometry, crop, camera matrix and unmix). It meters the grays with `measure_neutral_axis_from_log` against the frame's own bounds: first with the foliage and sky sectors excluded, against bounds from each channel's extreme cells with decoded zeros dropped, at confidence `MIN_CONFIDENCE` ($0.2$) or more. Else it reads against bounds from the `ROBUST_EXTREME_PCT` ($0.1$) percentiles, with the sectors excluded, then not, at `ROBUST_MIN_CONFIDENCE` ($0.4$). MIDTONE reads the midtone densities; SPREAD reads each channel's midtone-to-shadow spread. Both set $a_r = 1/\max(a_g, a_b, 1)$, so the best-kept layer keeps its density. Ratios clamp to $[0.2, 5]$, and the hint shows the unclamped value. The hint also shows the other method's reading: a large gap is a cast that a fade does not explain. A near-monochrome slide meters as faded. White Anchor (`fade_white_anchor`, UI "Remove Stain") makes the matrix affine, $M(L - w) + t$. The stain $w$ is each channel's `WHITE_PCT` cell of the meter grid in log transmittance, without decoded zeros and cells at full scale in every channel; Estimate stores it as `fade_white_r/g/b`. It maps to the neutral $t = \min(\max_c w_c, -0.1)$ (`FADE_WHITE_HEADROOM`): the cleanest channel's own level, never within $0.1$ D of full scale. $F^{-1}$ acts on $L - w$ before $t$ is added, so the Estimate reads the grays on $\min(L - w, 0)$. The offset rides in the w lanes of the unmix rows on the GPU. * **Density above base**: `density_above_base` is (Dmin, Dmed, Dmax) above the clear base, luminance-weighted: the thin and dense bounds (before trims; the roll's under Roll Analysis) and the metered median, each minus the base. The base is the picked one, else the rebate reader's, unmixed like the meters. A pick is the open roll's (`rolls.roll_film_base`), or the frame's own with no roll open (`process.film_base`, unhashed); in an open roll the roll's wins, with none open the frame's. The preview carries it as `film_base`; export never reads it. The reader takes the flat 8-px blocks (std over mean under 0.03, unclipped) in the outer 15% of the film box (`film_and_picture` at `AUTOCROP_DETECT_RES`), groups them by brightness with a 0.04-decade gap, and takes the largest group of at least five. It finds none without a film box, with a base darker than the picture, or on the transfer path. The preview reads it once per source, on the flat-fielded, unmixed source before the defect repairs, and passes the read to both engines. diff --git a/docs/USER_GUIDE.md b/docs/USER_GUIDE.md index 9da192165..1def3f736 100644 --- a/docs/USER_GUIDE.md +++ b/docs/USER_GUIDE.md @@ -533,7 +533,7 @@ Gives a faded slide its dye layers back. * **Red Survival**: how much red dye is left. Lower it until the contrast comes back. * **Green Survival** / **Blue Survival**: the same for green and blue, against red. * **Remove Stain**: makes yellowed clear areas white, and runs Estimate. Try it when Estimate turns its icon amber. -* **Estimate**: sets the sliders from the grays in this frame. **Midtone** also sets Red Survival and suits a badly faded slide. **Gray Range** suits a mild fade with deep shadows. +* **Estimate**: sets the sliders from the grays in this frame. **Midtone** suits a badly faded slide. **Gray Range** suits a mild fade with deep shadows. ### 10.5 Metering: negative → positive diff --git a/negpy/desktop/view/sidebar/sensor.py b/negpy/desktop/view/sidebar/sensor.py index b3e90dbda..2333c0de3 100644 --- a/negpy/desktop/view/sidebar/sensor.py +++ b/negpy/desktop/view/sidebar/sensor.py @@ -47,12 +47,8 @@ def _estimate_hint(method: "EstimateMethod", chosen: "FadeEstimate", other: "Fad frame holds a cast the fade does not explain.""" if chosen.reason and chosen.green == 1.0 and chosen.blue == 1.0: return chosen.reason - if chosen.red is not None: - text = tr("Estimated: red {r}, green {g}, blue {b}.", r=f"{chosen.red:.2f}", g=f"{chosen.green:.2f}", b=f"{chosen.blue:.2f}") - else: - text = tr( - "Estimated: green {g}, blue {b}. Red Survival is not changed: set it by eye.", g=f"{chosen.green:.2f}", b=f"{chosen.blue:.2f}" - ) + red = chosen.red if chosen.red is not None else 1.0 + text = tr("Estimated: red {r}, green {g}, blue {b}.", r=f"{red:.2f}", g=f"{chosen.green:.2f}", b=f"{chosen.blue:.2f}") if chosen.reason: text = chosen.reason + " " + text if not (other.reason and other.green == 1.0 and other.blue == 1.0): @@ -182,10 +178,8 @@ def _init_ui(self) -> None: tr("How Estimate reads the grays: Midtone suits a badly faded slide, Gray Range a mild fade with deep shadows."), [m.value for m in EstimateMethod], ) - self.estimate_method_combo.set_item_tooltip(0, tr("Midtone: from the color of the middle grays. It also sets Red Survival.")) - self.estimate_method_combo.set_item_tooltip( - 1, tr("Gray Range: from how far the grays reach into the shadows. It leaves Red Survival as it is.") - ) + self.estimate_method_combo.set_item_tooltip(0, tr("Midtone: from the color of the middle grays.")) + self.estimate_method_combo.set_item_tooltip(1, tr("Gray Range: from how far the grays reach into the shadows.")) saved = self.controller.session.repo.get_global_setting(_ESTIMATE_METHOD_KEY, EstimateMethod.MIDTONE) self.estimate_method_combo.setCurrentIndex( max(0, self.estimate_method_combo.findData(str(saved) if isinstance(saved, str) else "")) diff --git a/negpy/features/process/fade.py b/negpy/features/process/fade.py index 0b578bfb7..15c6ce736 100644 --- a/negpy/features/process/fade.py +++ b/negpy/features/process/fade.py @@ -182,7 +182,7 @@ class EstimateMethod(StrEnum): @dataclass(frozen=True) class FadeEstimate: - """Survivals from one Estimate. red is None when the method leaves Red Survival as it is; + """Survivals from one Estimate. red is None when no estimate was made; reason is "" for a real estimate (see fade_ratios_from_neutral_axis). white is the frame's measured white, None when the frame is not a slide or has no cell to read it from.""" @@ -236,7 +236,7 @@ def fade_ratios_from_neutral_axis(refs: Optional[NeutralAxisRefs], method: str = def estimate_fade(grid: ImageBuffer, white_anchor: bool = False) -> dict[EstimateMethod, FadeEstimate]: """Both methods' estimates from one measurement of the grays. `grid` is the transparency meter's grid with the crosstalk unmix, no fade and no Remove Stain (`TransparencyBaseProcessor`). - MIDTONE also sets Red Survival so the best-kept layer lands at 1.0; a ratio above 1 says red + Both methods set Red Survival so the best-kept layer lands at 1.0; a ratio above 1 says red faded most. With `white_anchor`, the grays are read as density above the frame's white, as Remove Stain renders them.""" white = measure_white(grid) @@ -250,7 +250,7 @@ def estimate_fade(grid: ImageBuffer, white_anchor: bool = False) -> dict[Estimat continue g, b, why = fade_ratios_from_neutral_axis(refs, method) red = None - if method == EstimateMethod.MIDTONE and not (why and g == 1.0 and b == 1.0): + if not (why and g == 1.0 and b == 1.0): lo, hi = RED_SURVIVAL_BOUNDS red = min(max(1.0 / max(g, b, 1.0), lo), hi) out[method] = FadeEstimate(red, g, b, why, white) diff --git a/negpy/translations/USER_GUIDE.de.md b/negpy/translations/USER_GUIDE.de.md index 68d0e916e..98c43977d 100644 --- a/negpy/translations/USER_GUIDE.de.md +++ b/negpy/translations/USER_GUIDE.de.md @@ -337,7 +337,7 @@ Für eine Kamera mit Schmalbandlicht, die von jedem Bild drei Aufnahmen macht: R Klicke mit der rechten Maustaste auf eine Hälfte für **Teilung für dieses Bild anpassen…** und **Teilung auf Rollenstandard zurücksetzen**. Wenn du Halbformat ausschaltest, bleiben bearbeitete Hälften als **Diptychon** nebeneinander. Schalte es wieder ein, um sie zu bearbeiten. - + ### 10.3 Kalibrierung: Farben deines Scan-Aufbaus Korrigiert Farbfehler, die von Lichtquelle, Kamera und Film kommen. @@ -363,7 +363,7 @@ Gibt einem ausgeblichenen Dia seine Farbstoffschichten zurück. * **Rot-Erhalt**: wie viel roter Farbstoff übrig ist. Senke den Wert, bis der Kontrast zurückkommt. * **Grün-Erhalt** / **Blau-Erhalt**: dasselbe für Grün und Blau, im Vergleich zu Rot. * **Schleier entfernen**: macht gelb gewordene klare Bereiche weiß und führt Schätzen aus. Probiere es, wenn Schätzen sein Symbol gelb-orange färbt. -* **Schätzen**: stellt die Regler aus den Grautönen dieses Bildes ein. **Mitteltöne** stellt auch den Rot-Erhalt ein und passt zu einem stark ausgeblichenen Dia. **Grauumfang** passt zu einer leichten Ausbleichung mit tiefen Schatten. +* **Schätzen**: stellt die Regler aus den Grautönen dieses Bildes ein. **Mitteltöne** passt zu einem stark ausgeblichenen Dia. **Grauumfang** passt zu einer leichten Ausbleichung mit tiefen Schatten. ### 10.5 Messung: Negativ → Positiv diff --git a/negpy/translations/USER_GUIDE.en.md b/negpy/translations/USER_GUIDE.en.md index 9da192165..1def3f736 100644 --- a/negpy/translations/USER_GUIDE.en.md +++ b/negpy/translations/USER_GUIDE.en.md @@ -533,7 +533,7 @@ Gives a faded slide its dye layers back. * **Red Survival**: how much red dye is left. Lower it until the contrast comes back. * **Green Survival** / **Blue Survival**: the same for green and blue, against red. * **Remove Stain**: makes yellowed clear areas white, and runs Estimate. Try it when Estimate turns its icon amber. -* **Estimate**: sets the sliders from the grays in this frame. **Midtone** also sets Red Survival and suits a badly faded slide. **Gray Range** suits a mild fade with deep shadows. +* **Estimate**: sets the sliders from the grays in this frame. **Midtone** suits a badly faded slide. **Gray Range** suits a mild fade with deep shadows. ### 10.5 Metering: negative → positive diff --git a/negpy/translations/USER_GUIDE.ja.md b/negpy/translations/USER_GUIDE.ja.md index c5912fc74..b301b54d9 100644 --- a/negpy/translations/USER_GUIDE.ja.md +++ b/negpy/translations/USER_GUIDE.ja.md @@ -346,7 +346,7 @@ NegPy は、フィルムスキャンをポジ画像に仕上げる仮想暗室 フレームはロールに従う場合を除き自身のプロファイルを保持するため、光源やフィルムフォーマットごとにプロファイルを持てます。新しいフレームと、プロファイルが見つからないフレームは、最後に選んだプロファイルを使います。 - + ## キャリブレーション:スキャン環境による色 光源、カメラ、フィルムから生じる色の誤差を補正します。 @@ -372,7 +372,7 @@ NegPy は、フィルムスキャンをポジ画像に仕上げる仮想暗室 - **赤の残存率**:残っている赤の色素の量。コントラストが戻るまで下げます。 - **緑の残存率** / **青の残存率**:緑と青についての同じ値で、赤と比べた量です。 - **しみ除去**:黄色くなった透明な部分を白にし、推定を実行します。推定でアイコンがアンバー色になったら試してください。 -- **推定**:このフレームのグレーからスライダーを設定します。**中間調**は赤の残存率も設定し、強く退色したスライドに向いています。**グレーの幅**は深いシャドウのある軽い退色に向いています。 +- **推定**:このフレームのグレーからスライダーを設定します。**中間調**は強く退色したスライドに向いています。**グレーの幅**は深いシャドウのある軽い退色に向いています。 ## 測光:ネガからポジへ diff --git a/negpy/translations/USER_GUIDE.ko.md b/negpy/translations/USER_GUIDE.ko.md index b138da43a..7f52b1aea 100644 --- a/negpy/translations/USER_GUIDE.ko.md +++ b/negpy/translations/USER_GUIDE.ko.md @@ -346,7 +346,7 @@ NegPy는 필름 스캔을 포지티브 이미지로 만드는 가상 암실입 프레임은 롤을 따르지 않는 한 자체 프로필을 유지하므로 광원과 필름 포맷마다 프로필을 둘 수 있습니다. 새 프레임과 프로필이 없는 프레임은 마지막으로 고른 프로필을 사용합니다. - + ## 보정: 스캔 환경에 따른 색 광원, 카메라, 필름에서 생기는 색 오차를 보정합니다. @@ -372,7 +372,7 @@ NegPy는 필름 스캔을 포지티브 이미지로 만드는 가상 암실입 - **빨강 잔존율**: 남아 있는 빨강 염료의 양입니다. 대비가 돌아올 때까지 낮추세요. - **초록 잔존율** / **파랑 잔존율**: 초록과 파랑에 대한 같은 값으로, 빨강과 비교한 양입니다. - **얼룩 제거**: 노랗게 변한 투명한 부분을 흰색으로 만들고 추정을 실행합니다. 추정 후 아이콘이 호박색이 되면 사용해 보세요. -- **추정**: 이 프레임의 회색으로 슬라이더를 설정합니다. **중간톤**은 빨강 잔존율도 설정하며 심하게 바랜 슬라이드에 맞습니다. **회색 범위**는 깊은 그림자가 있는 가벼운 퇴색에 맞습니다. +- **추정**: 이 프레임의 회색으로 슬라이더를 설정합니다. **중간톤**은 심하게 바랜 슬라이드에 맞습니다. **회색 범위**는 깊은 그림자가 있는 가벼운 퇴색에 맞습니다. ## 측광: 네거티브에서 포지티브로 diff --git a/negpy/translations/USER_GUIDE.zh_CN.md b/negpy/translations/USER_GUIDE.zh_CN.md index 263cff70e..f89da1f33 100644 --- a/negpy/translations/USER_GUIDE.zh_CN.md +++ b/negpy/translations/USER_GUIDE.zh_CN.md @@ -346,7 +346,7 @@ NegPy 通过虚拟暗房将胶片扫描制作为正像。源图像保持原样 除非帧跟随其胶卷,否则帧保留自己的配置,因此每种光源和胶片画幅都可以有一个配置。新帧或配置已缺失的帧使用你最后选择的配置。 - + ## 校准:扫描设备带来的颜色 校正来自光源、相机和胶片的颜色误差。 @@ -372,7 +372,7 @@ NegPy 通过虚拟暗房将胶片扫描制作为正像。源图像保持原样 - **红色残留率**:剩余的红色染料量。降低它,直到反差恢复。 - **绿色残留率** / **蓝色残留率**:绿色和蓝色的相同值,相对红色而言。 - **去除污渍**:让变黄的透明区域变白,并运行估算。估算使其图标变为琥珀色时可以试用。 -- **估算**:根据此画面中的灰色设置滑块。**中间调**同时设置红色残留率,适合严重褪色的反转片。**灰色范围**适合阴影较深的轻度褪色。 +- **估算**:根据此画面中的灰色设置滑块。**中间调**适合严重褪色的反转片。**灰色范围**适合阴影较深的轻度褪色。 ## 测光:从负片到正片 diff --git a/negpy/translations/de.json b/negpy/translations/de.json index 98ab90c6c..9935e4173 100644 --- a/negpy/translations/de.json +++ b/negpy/translations/de.json @@ -878,7 +878,6 @@ "Error: {text}": "Fehler: {text}", "Estimate": "Schätzen", "Estimate Fade Restoration from the grays in this frame": "Ausbleichrestaurierung aus den Grautönen dieses Bildes schätzen", - "Estimated: green {g}, blue {b}. Red Survival is not changed: set it by eye.": "Geschätzt: Grün {g}, Blau {b}. Rot-Erhalt bleibt unverändert: stelle ihn nach Augenmaß ein.", "Estimated: red {r}, green {g}, blue {b}.": "Geschätzt: Rot {r}, Grün {g}, Blau {b}.", "Even the fastest shutter with the LEDs at their minimum still clips the film base.": "Selbst die kürzeste Verschlusszeit bei minimaler LED-Helligkeit beschneidet noch die Filmbasis.", "Even the slowest shutter with the LEDs at their maximum stays below the exposure target.": "Selbst die längste Verschlusszeit bei maximaler LED-Helligkeit bleibt unter dem Belichtungsziel.", @@ -1146,7 +1145,7 @@ "Grain focuser loupe": "Kornschärfe-Lupe", "Grain focuser: shows the exported pixels at the pointer at their true size, with a sharpness number to compare areas.": "Kornschärfe-Lupe: Zeigt die exportierten Pixel am Zeiger in Originalgröße, mit einem Schärfewert zum Vergleichen von Bereichen.", "Gray Range": "Grauumfang", - "Gray Range: from how far the grays reach into the shadows. It leaves Red Survival as it is.": "Grauumfang: danach, wie weit die Grautöne in die Schatten reichen. Rot-Erhalt bleibt, wie er ist.", + "Gray Range: from how far the grays reach into the shadows.": "Grauumfang: danach, wie weit die Grautöne in die Schatten reichen.", "Grayscale": "Graustufen", "Green": "Grün", "Green #58": "Grün #58", @@ -1572,7 +1571,7 @@ "Mid Gray": "Mittelgrau", "Midtone": "Mitteltöne", "Midtone contrast, with no change to the deepest black and the paper white.": "Kontrast der Mitteltöne, ohne das tiefste Schwarz und das Papierweiß zu ändern.", - "Midtone: from the color of the middle grays. It also sets Red Survival.": "Mitteltöne: nach der Farbe der mittleren Grautöne. Stellt auch Rot-Erhalt ein.", + "Midtone: from the color of the middle grays.": "Mitteltöne: nach der Farbe der mittleren Grautöne.", "Minimize": "Minimieren", "Mode": "Modus", "Mode: B&W Negative": "Modus: S/W-Negativ", diff --git a/negpy/translations/ja.json b/negpy/translations/ja.json index e86394d9c..3fefc0c81 100644 --- a/negpy/translations/ja.json +++ b/negpy/translations/ja.json @@ -878,7 +878,6 @@ "Error: {text}": "エラー: {text}", "Estimate": "推定", "Estimate Fade Restoration from the grays in this frame": "このフレームのグレーから退色復元を推定", - "Estimated: green {g}, blue {b}. Red Survival is not changed: set it by eye.": "推定値:緑 {g}、青 {b}。赤の残存率は変わりません。目で見て設定してください。", "Estimated: red {r}, green {g}, blue {b}.": "推定値:赤 {r}、緑 {g}、青 {b}。", "Even the fastest shutter with the LEDs at their minimum still clips the film base.": "LEDを最小にして最速のシャッターを切ってもフィルムベースをクリップしてしまいます。", "Even the slowest shutter with the LEDs at their maximum stays below the exposure target.": "LED を最大に設定した最も遅いシャッターでも、露出目標を下回ったままになります。", @@ -1146,7 +1145,7 @@ "Grain focuser loupe": "グレインフォーカサールーペ", "Grain focuser: shows the exported pixels at the pointer at their true size, with a sharpness number to compare areas.": "グレインフォーカサー: ポインター位置の書き出しピクセルを等倍で表示し、領域を比べるためのシャープネス値を示します。", "Gray Range": "グレーの幅", - "Gray Range: from how far the grays reach into the shadows. It leaves Red Survival as it is.": "グレーの幅:グレーが影のどこまで届くかから推定します。赤の残存率はそのままです。", + "Gray Range: from how far the grays reach into the shadows.": "グレーの幅:グレーが影のどこまで届くかから推定します。", "Grayscale": "グレースケール", "Green": "緑", "Green #58": "緑 #58", @@ -1572,7 +1571,7 @@ "Mid Gray": "ミッドグレー", "Midtone": "中間調", "Midtone contrast, with no change to the deepest black and the paper white.": "中間調のコントラストで、最も深い黒と紙の白は変わりません。", - "Midtone: from the color of the middle grays. It also sets Red Survival.": "中間調:中間のグレーの色から推定します。赤の残存率も設定します。", + "Midtone: from the color of the middle grays.": "中間調:中間のグレーの色から推定します。", "Minimize": "最小化する", "Mode": "モード", "Mode: B&W Negative": "モード: 白黒ネガ", diff --git a/negpy/translations/ko.json b/negpy/translations/ko.json index 145203eb6..ca1e39fe5 100644 --- a/negpy/translations/ko.json +++ b/negpy/translations/ko.json @@ -878,7 +878,6 @@ "Error: {text}": "오류: {text}", "Estimate": "추정", "Estimate Fade Restoration from the grays in this frame": "이 프레임의 회색으로 퇴색 복원 추정", - "Estimated: green {g}, blue {b}. Red Survival is not changed: set it by eye.": "추정값: 초록 {g}, 파랑 {b}. 빨강 잔존율은 바뀌지 않으므로 눈으로 보며 설정하세요.", "Estimated: red {r}, green {g}, blue {b}.": "추정값: 빨강 {r}, 초록 {g}, 파랑 {b}.", "Even the fastest shutter with the LEDs at their minimum still clips the film base.": "최소한의 LED를 사용하는 가장 빠른 셔터라도 여전히 필름 베이스를 클립합니다.", "Even the slowest shutter with the LEDs at their maximum stays below the exposure target.": "LED가 최대인 가장 느린 셔터라도 노출 목표 미만으로 유지됩니다.", @@ -1146,7 +1145,7 @@ "Grain focuser loupe": "그레인 포커서 루페", "Grain focuser: shows the exported pixels at the pointer at their true size, with a sharpness number to compare areas.": "그레인 포커서: 포인터 위치의 내보낸 픽셀을 실제 크기로 보여 주고, 영역을 비교할 선명도 수치를 표시합니다.", "Gray Range": "회색 범위", - "Gray Range: from how far the grays reach into the shadows. It leaves Red Survival as it is.": "회색 범위: 회색이 그림자까지 얼마나 이어지는지로 추정합니다. 빨강 잔존율은 그대로 둡니다.", + "Gray Range: from how far the grays reach into the shadows.": "회색 범위: 회색이 그림자까지 얼마나 이어지는지로 추정합니다.", "Grayscale": "그레이스케일", "Green": "초록", "Green #58": "초록 #58", @@ -1572,7 +1571,7 @@ "Mid Gray": "미드 그레이", "Midtone": "중간톤", "Midtone contrast, with no change to the deepest black and the paper white.": "중간 톤 대비로, 가장 깊은 검정과 인화지 흰색은 변하지 않습니다.", - "Midtone: from the color of the middle grays. It also sets Red Survival.": "중간톤: 중간 회색의 색으로 추정합니다. 빨강 잔존율도 설정합니다.", + "Midtone: from the color of the middle grays.": "중간톤: 중간 회색의 색으로 추정합니다.", "Minimize": "최소화", "Mode": "모드", "Mode: B&W Negative": "모드: 흑백 네거티브", diff --git a/negpy/translations/zh_CN.json b/negpy/translations/zh_CN.json index b7bec2254..dfc0acb03 100644 --- a/negpy/translations/zh_CN.json +++ b/negpy/translations/zh_CN.json @@ -878,7 +878,6 @@ "Error: {text}": "错误:{text}", "Estimate": "估算", "Estimate Fade Restoration from the grays in this frame": "根据此画面中的灰色估算褪色修复", - "Estimated: green {g}, blue {b}. Red Survival is not changed: set it by eye.": "估算结果:绿色 {g},蓝色 {b}。红色残留率不会改变,请目测设置。", "Estimated: red {r}, green {g}, blue {b}.": "估算结果:红色 {r},绿色 {g},蓝色 {b}。", "Even the fastest shutter with the LEDs at their minimum still clips the film base.": "LED 最低亮度配合最快快门,片基仍然截断。", "Even the slowest shutter with the LEDs at their maximum stays below the exposure target.": "LED 最高亮度配合最慢快门,仍达不到曝光目标。", @@ -1146,7 +1145,7 @@ "Grain focuser loupe": "颗粒对焦放大镜", "Grain focuser: shows the exported pixels at the pointer at their true size, with a sharpness number to compare areas.": "颗粒对焦镜:以实际大小显示指针处的导出像素,并给出锐度数值,便于比较不同区域。", "Gray Range": "灰色范围", - "Gray Range: from how far the grays reach into the shadows. It leaves Red Survival as it is.": "灰色范围:根据灰色延伸到阴影的程度估算。红色残留率保持不变。", + "Gray Range: from how far the grays reach into the shadows.": "灰色范围:根据灰色延伸到阴影的程度估算。", "Grayscale": "灰度", "Green": "绿色", "Green #58": "绿 #58", @@ -1572,7 +1571,7 @@ "Mid Gray": "中灰", "Midtone": "中间调", "Midtone contrast, with no change to the deepest black and the paper white.": "中间调反差,最深的黑和相纸白不变。", - "Midtone: from the color of the middle grays. It also sets Red Survival.": "中间调:根据中间灰色的颜色估算,同时设置红色残留率。", + "Midtone: from the color of the middle grays.": "中间调:根据中间灰色的颜色估算。", "Minimize": "最小化", "Mode": "模式", "Mode: B&W Negative": "模式:黑白负片", diff --git a/tests/test_fade_estimation.py b/tests/test_fade_estimation.py index afa579d48..2b9706bbd 100644 --- a/tests/test_fade_estimation.py +++ b/tests/test_fade_estimation.py @@ -123,16 +123,16 @@ def test_no_neutral_axis_found_fails_closed(): assert reason -def test_both_methods_recover_a_pure_fade_and_midtone_sets_red(): - """On a pure fade both methods read the same ratios; Midtone also puts the best-kept layer at 1.0.""" +def test_both_methods_recover_a_pure_fade_and_set_red(): + """On a pure fade both methods read the same ratios and put the best-kept layer at 1.0.""" image = _synthetic_neutral_slide(1.4, 1.2, ratio_r=0.7) estimates = estimate_fade(prefilter_log_grid(image, None, 0.0)) midtone, spread = estimates[EstimateMethod.MIDTONE], estimates[EstimateMethod.SPREAD] for est in (midtone, spread): assert est.reason == "" assert abs(est.green - 1.4) < 2e-2 and abs(est.blue - 1.2) < 2e-2 - assert spread.red is None - assert abs(midtone.red - 1.0 / midtone.green) < 1e-9 + for est in (midtone, spread): + assert abs(est.red - 1.0 / est.green) < 1e-9 def test_midtone_ignores_a_lifted_shadow_that_misleads_spread():