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 .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -129,4 +129,4 @@ jobs:
run: uv sync --group dev --group plustek

- name: negpy_core pins and oracles
run: uv run pytest tests/test_percentiles.py tests/test_core_same_bits.py tests/test_core_resize.py tests/test_core_filter.py tests/test_core_morph.py tests/test_core_gray.py tests/test_core_contours.py tests/test_core_shapes.py tests/test_core_components.py tests/test_core_color.py tests/test_core_warp.py tests/test_core_lab.py tests/test_core_altprocess.py
run: uv run pytest tests/test_percentiles.py tests/test_core_same_bits.py tests/test_core_orient.py tests/test_core_ir_downsample.py tests/test_core_flat_curve.py tests/test_core_png.py tests/test_core_resize.py tests/test_core_filter.py tests/test_core_morph.py tests/test_core_gray.py tests/test_core_contours.py tests/test_core_shapes.py tests/test_core_components.py tests/test_core_color.py tests/test_core_warp.py tests/test_core_lab.py tests/test_core_altprocess.py
3 changes: 2 additions & 1 deletion NOTICE.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@ Apache-2.0), ndarray, num-traits, num-complex, num-integer, matrixmultiply and
rawpointer (MIT OR Apache-2.0), libc, once_cell, either, portable-atomic and
rustc-hash (MIT OR Apache-2.0), multiversion and target-features (MIT OR Apache-2.0), libm (MIT, a port of
musl's math library), rustfft, realfft, primal-check, strength_reduce and transpose (MIT OR
Apache-2.0).
Apache-2.0), miniz_oxide (MIT OR Zlib OR Apache-2.0), adler2 (0BSD OR MIT OR Apache-2.0),
crc32fast and cfg-if (MIT OR Apache-2.0).
negpy_core/Cargo.lock lists the exact versions.

negpy_core/src/resize.rs and negpy_core/src/filter.rs port OpenCV's float32 area
Expand Down
1 change: 1 addition & 0 deletions docs/PIPELINE.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ Pixel values are linear transmittance. The source corrections run in that domain
* **Waveform**: encoded luma in 128 bins for each of 256 columns (`WAVEFORM_BINS`, `WAVEFORM_COLS`), with each cell's mean color (`WAVEFORM_COLOR_SCALE`) and a per-channel parade. The density pass bins scan density per crop column for the scan Parade (`SCAN_DENSITY_RANGE`, inverting the stretch) and counts linear pixels at `scan_clip_level` before the unmix. The base line is `normalization.base_scan_density`.
* **Vectorscope**: CIELAB a\*b\* in 180 hue × 64 chroma bins, log chroma with a knee at 4, last bin past chroma 60 (`SCOPE_*`).
* **Input ICC**: overrides primaries only. A matrix/TRC profile contributes its primaries (Bradford-adapted to D65 through its `chad` tag) as a plain matrix, so its TRC is inert. A LUT (A2B0/B2A0) profile runs the full CMS transform.
* **Export transform**: between two matrix/TRC profiles (every bundled export space), 8- and 16-bit exports run `matrix_trc_u8`/`matrix_trc_u16`: TRC decode, $M_{dst,PCS}^{-1} M_{src,PCS}$ with a gamut clip, TRC encode. The 16-bit encode table samples linear values at $t^2$, so it stays within one level of lcms without lcms's grid optimization. LUT profiles go through lcms.

### Automatic helpers

Expand Down
19 changes: 3 additions & 16 deletions negpy/features/exposure/logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,17 +59,6 @@ def separation_damping_gain(k: float, damping: float, chroma: float, ref_spread:
return float(kf)


def separation_damping_gain_np(k: float, damping: float, chroma: Any, ref_spread: float) -> Any:
"""Vectorized numpy twin of separation_damping_gain, for the transfer curve's
per-pixel gain over a whole image rather than the print curve's per-pixel scalar.
Same math; see that function for the derivation."""
if k <= 0.0:
return np.zeros_like(chroma, dtype=np.float32)
h = (np.float32(ref_spread) - chroma) / (np.float32(ref_spread) + chroma)
kf = np.power(np.float32(k), (1.0 - np.float32(damping)) + np.float32(damping) * h, dtype=np.float32)
return np.minimum(kf, np.float32(3.0))


def tone_key_weight_np(lum: Any, e0: float, e1: float) -> Any:
"""A tone-limited mask's weight at luma `lum`, over a whole raster: a smoothstep from 0 at e0
to 1 at e1 (see placement.key_edges). The print curve in negpy_core and exposure.wgsl mirror it."""
Expand Down Expand Up @@ -558,11 +547,9 @@ def apply_flat_curve(
editing. WB rides as an additive per-channel shift in log space; the print
stock's density matrix (rows sum 1) mixes after it, as in the print kernel.
"""
val = np.asarray(image, dtype=np.float32) + np.asarray(cmy_offsets, dtype=np.float32)
if stock_matrix is not None:
val = val @ np.asarray(stock_matrix, dtype=np.float32).T
code = lift + gain * (1.0 - val)
return ensure_image(np.clip(code, 0.0, 1.0).astype(np.float32))
m = None if stock_matrix is None else np.ascontiguousarray(stock_matrix, dtype=np.float32)
offsets = tuple(float(np.float32(o)) for o in cmy_offsets)
return ensure_image(negpy_core.flat_curve(np.ascontiguousarray(image, dtype=np.float32), float(gain), float(lift), offsets, m))


def default_grade_range() -> float:
Expand Down
24 changes: 1 addition & 23 deletions negpy/features/finish/logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -114,26 +114,4 @@ def apply_vignette(img: ImageBuffer, stops: float, size: float, roundness: float
"""
if stops == 0.0:
return img

h, w = img.shape[:2]
cy, cx = (h - 1) * 0.5, (w - 1) * 0.5

y_coords = np.arange(h, dtype=np.float32)
x_coords = np.arange(w, dtype=np.float32)
yy, xx = np.meshgrid(y_coords, x_coords, indexing="ij")
max_dist = float(np.sqrt(cx * cx + cy * cy))
d_radial = np.sqrt((xx - cx) ** 2 + (yy - cy) ** 2) / max(max_dist, 1.0)
d_rect = np.maximum(np.abs(xx - cx) / max(cx, 1.0), np.abs(yy - cy) / max(cy, 1.0))
dist = d_radial * (1.0 - roundness) + d_rect * roundness

# Remap: size=0 → vignette barely at corners, size=1 → covers entire image
midpoint = 1.0 - size
t = (dist - midpoint) / max(1.0 - midpoint, 1e-6)
t = np.clip(t, 0.0, 1.0)

# Smooth cosine falloff
factor = 0.5 * (1.0 - np.cos(t * np.pi))

result = img * np.exp2(-stops * factor[:, :, np.newaxis])

return ensure_image(np.clip(result, 0.0, 1.0))
return ensure_image(negpy_core.apply_vignette(np.ascontiguousarray(img, dtype=np.float32), float(stops), float(size), float(roundness)))
15 changes: 3 additions & 12 deletions negpy/features/retouch/logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -225,7 +225,7 @@
def _density(img: ImageBuffer) -> np.ndarray:
"""Log density of the luminance. Local contrast measured here is exposure-invariant:
a gain on the source is an offset in density and cancels in every difference."""
return -np.log10(np.clip(get_luminance(img), 1e-6, None)).astype(np.float32)
return -negpy_core.log_density_open(get_luminance(img))


def _mask_to_score(mask: np.ndarray, pad_px: float) -> np.ndarray:
Expand Down Expand Up @@ -674,17 +674,8 @@ def _erode_resize_bounded(plane: np.ndarray, dims: Tuple[int, int], kernel: np.n
eroded = negpy_core.morphology_ex(source, negpy_core.MORPH_ERODE, kernel)
core = np.ascontiguousarray(eroded[source_top - read_top : source_bottom - read_top])
horizontal = negpy_core.resize_area(core, (dw, len(core)))
# Keep the full-image sampling grid across fractional block boundaries.
for row in range(top, bottom):
start = row * scale_y
end = start + scale_y
first, last = math.floor(start), min(h, math.ceil(end))
indices = np.arange(first, last)
overlap = np.minimum(indices + 1, end) - np.maximum(indices, start)
# OpenCV's area table omits fractional edges at or below this cutoff.
weights = (np.where(overlap > 1e-3, overlap, 0.0) / min(scale_y, h - start)).astype(np.float32)
values = horizontal[first - source_top : last - source_top]
output[row] = np.sum(values * weights.reshape((-1,) + (1,) * (values.ndim - 1)), axis=0)
# The vertical pass keeps the full-image sampling grid across fractional block boundaries.
negpy_core.area_rows_into(horizontal, output[top:bottom], top, source_top, scale_y, h)
return output


Expand Down
164 changes: 46 additions & 118 deletions negpy/features/transparency/logic.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@

from typing import Any, Dict, Mapping, Optional, Tuple

import negpy_core
import numpy as np

from negpy.domain.types import ImageBuffer
Expand All @@ -45,7 +46,6 @@
per_channel_dye_separation,
per_channel_toe_shoulder,
per_channel_widths,
separation_damping_gain_np,
slope_to_grade,
)
from negpy.features.exposure.models import EXPOSURE_CONSTANTS, ExposureConfig
Expand Down Expand Up @@ -155,31 +155,9 @@ def display_rendering(scene_linear: np.ndarray) -> np.ndarray:
return np.clip(num / np.maximum(den, np.float32(1e-8)), 0.0, 1.0).astype(np.float32)


def _sigmoid(x: np.ndarray) -> np.ndarray:
"""Logistic sigmoid, overflow-safe for large |x| (mirrors logic.py::_fast_sigmoid)."""
return (0.5 * (1.0 + np.tanh(0.5 * np.asarray(x, dtype=np.float32)))).astype(np.float32)


ZONE_BLACK_TAPER = 1.0


def _black_taper(d: np.ndarray, density_range: float) -> np.ndarray:
"""Fades a shadow lift back to nothing at the bottom of the window.

On a print, Zone Density is bounded by paper black -- a shadow burn cannot exceed
d_max. This curve has no paper, so without a bound a lift walks the black point up
with it and the frame simply stops having blacks. Smoothstep so the taper adds no kink.
"""
t = np.clip((np.float32(density_range) - d) / np.float32(ZONE_BLACK_TAPER), 0.0, 1.0)
return (t * t * (np.float32(3.0) - np.float32(2.0) * t)).astype(np.float32)


def _softplus(x: np.ndarray, width: float) -> np.ndarray:
"""width * log(1 + exp(x / width)), overflow-safe: ~x for x >> width, ~0 for x << -width."""
t = x / width
return (width * (np.logaddexp(0.0, -np.abs(t)) + np.maximum(t, 0.0))).astype(np.float32)


def transfer_widths(config: ExposureConfig) -> Tuple[Tuple[float, float, float], Tuple[float, float, float]]:
"""
Per-channel knee softness, in density, from the Toe/Shoulder Width sliders. Scaled
Expand Down Expand Up @@ -424,102 +402,52 @@ def apply_transfer_curve(
"""
c = TRANSFER_CONSTANTS
base_width = float(c["transfer_knee_width"])
tw3 = toe_widths or (base_width, base_width, base_width)
sw3 = shoulder_widths or (base_width, base_width, base_width)
pivot = float(c["transfer_contrast_pivot"])
toe_knee = float(c["transfer_toe_knee"])
sh_knee = float(c["transfer_shoulder_knee"])

def shape(d: np.ndarray, ch: int) -> np.ndarray:
"""One channel's capture density through every control above Dye Separation."""

# Cast Removal: the channel's neutral refs onto green's. First, so every control
# below shapes the corrected signal.
if cast_gain[ch] != 1.0 or cast_offset[ch] != 0.0:
d = d * np.float32(cast_gain[ch]) + np.float32(cast_offset[ch])

if exposure_offset != 0.0 or cmy_offsets[ch] != 0.0:
# cmy_offsets arrive in normalized space, because filtration_offsets divides by the
# channel's stretch range, so they scale back up by the same range.
d = d - np.float32(exposure_offset) + np.float32(cmy_offsets[ch] * density_range)

if contrast != 1.0:
d = np.float32(pivot) + (d - np.float32(pivot)) * np.float32(contrast)

# Shadows/Highlights WB: regional CMY, using the print path's own kernel
# (logic.py, "Regional CMY") and geometry (wb_split_geometry).
if shadow_cmy[ch] != 0.0 or highlight_cmy[ch] != 0.0:
wb_c, wb_k = wb_split_geometry()
w_sh = _sigmoid(np.float32(wb_k) * (d - np.float32(wb_c)))
w_hi = np.float32(1.0) - w_sh
d = d + np.float32(shadow_cmy[ch]) * w_sh + np.float32(highlight_cmy[ch]) * w_hi

# Zone Density: mid-sparing brightness offsets, using the print path's own kernel and
# weights (logic.py, "Zone Density (ΔD)"). Positive adds density, so it darkens: the
# Density convention, the opposite sign to a Lightroom Shadows slider. Runs after
# contrast and before the knees, as it does on the print.
if shadow_density != 0.0 or highlight_density != 0.0:
sh_c, hi_c, k = zone_geometry()
w_sh = _sigmoid(np.float32(k) * (d - np.float32(sh_c)))
w_sh = w_sh * _black_taper(d, density_range)
w_hi = np.float32(1.0) - _sigmoid(np.float32(k) * (d - np.float32(hi_c)))
d = d + np.float32(shadow_density) * w_sh + np.float32(highlight_density) * w_hi

# Shadows sit at high density and highlights at low, so the toe compresses above its knee
# and the shoulder below its own.
if toe[ch] != 0.0:
d = d - np.float32(toe[ch]) * _softplus(d - np.float32(toe_knee), tw3[ch])
if shoulder[ch] != 0.0:
d = d + np.float32(shoulder[ch]) * _softplus(np.float32(sh_knee) - d, sw3[ch])

return d

n = np.asarray(img_norm, dtype=np.float32)
capture = n * np.float32(density_range)
dens = np.empty_like(n)
for ch in range(3):
dens[:, :, ch] = shape(capture[:, :, ch], ch)

wb_c, wb_k = wb_split_geometry()
sh_c, hi_c, zone_k = zone_geometry()
sep_k3 = per_channel_dye_separation(separation, separation_trims)
if sep_k3 != (1.0, 1.0, 1.0):
# Both softplus steps rise with their input, so d_ref never falls as a channel
# darkens; a reference that can fall folds a smooth gradient into bands. The cap is
# decided on the capture, where the window clamp always sits at the dense edge, so
# no edit can move a clamped channel back under it.
d_lo = capture.min(axis=2, keepdims=True)
cap = np.float32(SEPARATION_CAP_LEVEL) + _softplus(
d_lo + np.float32(SEPARATION_CAP_SPREAD - SEPARATION_CAP_LEVEL), SEPARATION_CAP_SOFTNESS
)
capped = cap - _softplus(cap - capture, SEPARATION_CAP_SOFTNESS)
capped = np.stack([shape(capped[:, :, ch], ch) for ch in range(3)], axis=2)
d_ref = capped.mean(axis=2, keepdims=True)
e = dens - d_ref
if damping > 0.0:
# Separation Damping makes each channel's k chroma-dependent per pixel, from
# the same chroma but each channel's own k (see separation_damping_gain_np).
# The chroma reads the capped channels too.
ce = capped - d_ref
chroma = np.sqrt(((ce[:, :, 0] - ce[:, :, 1]) ** 2 + (ce[:, :, 1] - ce[:, :, 2]) ** 2 + (ce[:, :, 0] - ce[:, :, 2]) ** 2) / 3.0)
ref_spread = float(EXPOSURE_CONSTANTS["separation_damping_ref_spread"])
k_eff = np.stack(
[separation_damping_gain_np(sep_k3[ch], damping, chroma, ref_spread) for ch in range(3)],
axis=2,
)
dens = d_ref + k_eff * e
else:
k3 = np.asarray(sep_k3, dtype=np.float32)
dens = d_ref + k3[np.newaxis, np.newaxis, :] * e

out = np.power(np.float32(10.0), -dens, dtype=np.float32)

# Baseline and display rendering last, so the controls above shape the scene and this
# only decides how the scene is shown. A finished positive sits nowhere below a sensor
# white level, so neither belongs: it passes through at scene scale, clipped to the
# same range display_rendering outputs.
if positive_source:
return ensure_image(np.clip(out, 0.0, 1.0))
gain = np.float32(2.0 ** float(c["transfer_baseline_ev"]))
return ensure_image(display_rendering(out * gain))
params = {
"density_range": density_range,
"cast_gain": tuple(cast_gain),
"cast_offset": tuple(cast_offset),
"cast_on": tuple(cast_gain[ch] != 1.0 or cast_offset[ch] != 0.0 for ch in range(3)),
"exposure_offset": exposure_offset,
# cmy_offsets arrive in normalized space, because filtration_offsets divides by the
# channel's stretch range, so they scale back up by the same range.
"cmy": tuple(cmy_offsets[ch] * density_range for ch in range(3)),
"exposure_on": tuple(exposure_offset != 0.0 or cmy_offsets[ch] != 0.0 for ch in range(3)),
"contrast": contrast,
"contrast_on": contrast != 1.0,
"pivot": float(c["transfer_contrast_pivot"]),
"shadow_cmy": tuple(shadow_cmy),
"highlight_cmy": tuple(highlight_cmy),
"regional_on": tuple(shadow_cmy[ch] != 0.0 or highlight_cmy[ch] != 0.0 for ch in range(3)),
"wb_c": wb_c,
"wb_k": wb_k,
"shadow_density": shadow_density,
"highlight_density": highlight_density,
"zone_on": shadow_density != 0.0 or highlight_density != 0.0,
"zone_sh": sh_c,
"zone_hi": hi_c,
"zone_k": zone_k,
"black_taper": ZONE_BLACK_TAPER,
"toe": tuple(toe),
"shoulder": tuple(shoulder),
"toe_width": tuple(toe_widths or (base_width,) * 3),
"shoulder_width": tuple(shoulder_widths or (base_width,) * 3),
"toe_knee": float(c["transfer_toe_knee"]),
"shoulder_knee": float(c["transfer_shoulder_knee"]),
"sep_k": sep_k3,
"sep_on": sep_k3 != (1.0, 1.0, 1.0),
"cap_level": SEPARATION_CAP_LEVEL,
"cap_offset": SEPARATION_CAP_SPREAD - SEPARATION_CAP_LEVEL,
"cap_softness": SEPARATION_CAP_SOFTNESS,
"damping": damping,
"damping_on": damping > 0.0,
"ref_spread": float(EXPOSURE_CONSTANTS["separation_damping_ref_spread"]),
"positive_source": bool(positive_source),
"gain": 2.0 ** float(c["transfer_baseline_ev"]),
}
return ensure_image(negpy_core.apply_transfer_curve(np.ascontiguousarray(img_norm, dtype=np.float32), params))


def transfer_point_offsets(process: ProcessConfig) -> Tuple[Tuple[float, float, float], Tuple[float, float, float]]:
Expand Down
2 changes: 1 addition & 1 deletion negpy/features/transparency/shaders/transfer.wgsl
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,7 @@ struct TransferUniforms {
@group(0) @binding(1) var output_tex: texture_storage_2d<rgba32float, write>;
@group(0) @binding(2) var<uniform> params: TransferUniforms;

// width * log(1 + exp(x / width)), overflow-safe — mirrors logic.py::_softplus.
// width * log(1 + exp(x / width)), overflow-safe — mirrors softplus in negpy_core/src/transfer.rs.
fn softplus(x: f32, width: f32) -> f32 {
let t = x / width;
return width * (log(1.0 + exp(-abs(t))) + max(t, 0.0));
Expand Down
Loading
Loading