Skip to content
186 changes: 186 additions & 0 deletions NEGATIVE_INVERSION.md

Large diffs are not rendered by default.

117 changes: 117 additions & 0 deletions src-tauri/src/image_processing.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1333,6 +1333,27 @@ pub struct GlobalAdjustments {
pub halation_amount: f32,
pub flare_amount: f32,
pub sharpness_threshold: f32,

pub negative_enabled: u32,
pub negative_red_min: f32,
pub negative_red_max: f32,
pub negative_green_min: f32,
pub negative_green_max: f32,
pub negative_blue_min: f32,
pub negative_blue_max: f32,
pub negative_red_weight: f32,
pub negative_green_weight: f32,
pub negative_blue_weight: f32,
pub negative_exposure: f32,
pub negative_contrast: f32,
pub negative_toe: f32,
pub negative_toe_width: f32,
pub negative_shoulder: f32,
pub negative_shoulder_width: f32,
pub negative_black_clip: f32,
pub negative_white_clip: f32,
pub _pad_neg1: f32,
pub _pad_neg2: f32,
}

#[derive(Serialize, Deserialize, Debug, Clone, Copy, Pod, Zeroable, Default)]
Expand Down Expand Up @@ -2144,6 +2165,102 @@ fn get_global_adjustments_from_json(
SCALES.sharpness_threshold,
Some(15.0),
),

negative_enabled: {
let neg = js_adjustments.get("negative");
let enabled = neg
.and_then(|n| n.get("enabled"))
.and_then(|v| v.as_bool())
.unwrap_or(false);
if enabled && is_visible("negative") { 1 } else { 0 }
},
negative_red_min: js_adjustments
.get("negative")
.and_then(|n| n.get("redMin"))
.and_then(|v| v.as_f64())
.unwrap_or(0.0) as f32,
negative_red_max: js_adjustments
.get("negative")
.and_then(|n| n.get("redMax"))
.and_then(|v| v.as_f64())
.unwrap_or(1.0) as f32,
negative_green_min: js_adjustments
.get("negative")
.and_then(|n| n.get("greenMin"))
.and_then(|v| v.as_f64())
.unwrap_or(0.0) as f32,
negative_green_max: js_adjustments
.get("negative")
.and_then(|n| n.get("greenMax"))
.and_then(|v| v.as_f64())
.unwrap_or(1.0) as f32,
negative_blue_min: js_adjustments
.get("negative")
.and_then(|n| n.get("blueMin"))
.and_then(|v| v.as_f64())
.unwrap_or(0.0) as f32,
negative_blue_max: js_adjustments
.get("negative")
.and_then(|n| n.get("blueMax"))
.and_then(|v| v.as_f64())
.unwrap_or(1.0) as f32,
negative_red_weight: js_adjustments
.get("negative")
.and_then(|n| n.get("redWeight"))
.and_then(|v| v.as_f64())
.unwrap_or(1.0) as f32,
negative_green_weight: js_adjustments
.get("negative")
.and_then(|n| n.get("greenWeight"))
.and_then(|v| v.as_f64())
.unwrap_or(1.0) as f32,
negative_blue_weight: js_adjustments
.get("negative")
.and_then(|n| n.get("blueWeight"))
.and_then(|v| v.as_f64())
.unwrap_or(1.0) as f32,
negative_exposure: js_adjustments
.get("negative")
.and_then(|n| n.get("exposure"))
.and_then(|v| v.as_f64())
.unwrap_or(0.0) as f32,
negative_contrast: js_adjustments
.get("negative")
.and_then(|n| n.get("contrast"))
.and_then(|v| v.as_f64())
.unwrap_or(1.0) as f32,
negative_toe: js_adjustments
.get("negative")
.and_then(|n| n.get("toe"))
.and_then(|v| v.as_f64())
.unwrap_or(0.0) as f32,
negative_toe_width: js_adjustments
.get("negative")
.and_then(|n| n.get("toeWidth"))
.and_then(|v| v.as_f64())
.unwrap_or(2.5) as f32,
negative_shoulder: js_adjustments
.get("negative")
.and_then(|n| n.get("shoulder"))
.and_then(|v| v.as_f64())
.unwrap_or(0.0) as f32,
negative_shoulder_width: js_adjustments
.get("negative")
.and_then(|n| n.get("shoulderWidth"))
.and_then(|v| v.as_f64())
.unwrap_or(2.5) as f32,
negative_black_clip: js_adjustments
.get("negative")
.and_then(|n| n.get("blackClip"))
.and_then(|v| v.as_f64())
.unwrap_or(0.0) as f32,
negative_white_clip: js_adjustments
.get("negative")
.and_then(|n| n.get("whiteClip"))
.and_then(|v| v.as_f64())
.unwrap_or(0.0) as f32,
_pad_neg1: 0.0,
_pad_neg2: 0.0,
}
}

Expand Down
1 change: 1 addition & 0 deletions src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -2317,6 +2317,7 @@ pub fn run() {
lens_correction::get_lens_distortion_params,
negative_conversion::preview_negative_conversion,
negative_conversion::convert_negatives,
negative_conversion::analyze_negative_bounds,
])
.build(tauri::generate_context!())
.expect("error while building tauri application")
Expand Down
59 changes: 58 additions & 1 deletion src-tauri/src/negative_conversion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ pub struct ChannelBounds {
pub max: f32,
}

fn analyze_bounds(log_data: &[f32], width: usize, height: usize) -> [ChannelBounds; 3] {
pub fn analyze_bounds(log_data: &[f32], width: usize, height: usize) -> [ChannelBounds; 3] {
let margin_x = (width as f32 * 0.12) as usize;
let margin_y = (height as f32 * 0.12) as usize;

Expand Down Expand Up @@ -280,6 +280,63 @@ pub async fn preview_negative_conversion(
Ok(format!("data:image/jpeg;base64,{}", base64_str))
}

#[tauri::command]
pub async fn analyze_negative_bounds(
path: String,
js_adjustments: serde_json::Value,
state: tauri::State<'_, AppState>,
app_handle: AppHandle,
) -> Result<[f32; 6], String> {
let (source_path, _) = parse_virtual_path(&path);
let source_path_str = source_path.to_string_lossy().to_string();

let full_res_image = {
let original_lock = state.original_image.lock().unwrap();
if let Some(loaded) = original_lock.as_ref()
&& loaded.path == source_path_str
{
loaded.image.clone().as_ref().clone()
} else {
drop(original_lock);
let settings = load_settings(app_handle.clone()).unwrap_or_default();
match read_file_mapped(Path::new(&source_path_str)) {
Ok(mmap) => {
load_base_image_from_bytes(&mmap, &source_path_str, false, &settings, None)
.map_err(|e| e.to_string())?
}
Err(_) => {
let bytes =
fs::read(&source_path_str).map_err(|io_err| io_err.to_string())?;
load_base_image_from_bytes(&bytes, &source_path_str, false, &settings, None)
.map_err(|e| e.to_string())?
}
}
}
};

let (transformed, _offset) =
crate::adjustment_utils::apply_all_transformations(&full_res_image, &js_adjustments);
let analysis_image = downscale_f32_image(transformed.as_ref(), 1080, 1080);

let rgb = analysis_image.to_rgb32f();
let (width, height) = rgb.dimensions();
let log_pixels: Vec<f32> = rgb
.as_raw()
.par_iter()
.map(|&v| -v.clamp(1e-6, 1.0).log10())
.collect();

let bounds = analyze_bounds(&log_pixels, width as usize, height as usize);
Ok([
bounds[0].min,
bounds[0].max,
bounds[1].min,
bounds[1].max,
bounds[2].min,
bounds[2].max,
])
}

#[tauri::command]
pub async fn convert_negatives(
paths: Vec<String>,
Expand Down
97 changes: 97 additions & 0 deletions src-tauri/src/shaders/shader.wgsl
Original file line number Diff line number Diff line change
Expand Up @@ -111,6 +111,27 @@ struct GlobalAdjustments {
halation_amount: f32,
flare_amount: f32,
sharpness_threshold: f32,

negative_enabled: u32,
negative_red_min: f32,
negative_red_max: f32,
negative_green_min: f32,
negative_green_max: f32,
negative_blue_min: f32,
negative_blue_max: f32,
negative_red_weight: f32,
negative_green_weight: f32,
negative_blue_weight: f32,
negative_exposure: f32,
negative_contrast: f32,
negative_toe: f32,
negative_toe_width: f32,
negative_shoulder: f32,
negative_shoulder_width: f32,
negative_black_clip: f32,
negative_white_clip: f32,
_pad_neg1: f32,
_pad_neg2: f32,
}

struct MaskAdjustments {
Expand Down Expand Up @@ -230,6 +251,78 @@ fn linear_to_srgb(c: vec3<f32>) -> vec3<f32> {
return select(higher, lower, c_clamped <= cutoff);
}

fn apply_negative_inversion(rgb_linear: vec3<f32>) -> vec3<f32> {
let mins = vec3<f32>(
adjustments.global.negative_red_min,
adjustments.global.negative_green_min,
adjustments.global.negative_blue_min,
);
let maxs = vec3<f32>(
adjustments.global.negative_red_max,
adjustments.global.negative_green_max,
adjustments.global.negative_blue_max,
);
let weights = vec3<f32>(
adjustments.global.negative_red_weight,
adjustments.global.negative_green_weight,
adjustments.global.negative_blue_weight,
);

let inv_log10 = 1.0 / log(10.0);
let log_rgb = -log(max(rgb_linear, vec3<f32>(1e-6))) * inv_log10;
let raw_range = max(maxs - mins, vec3<f32>(1e-4));
let adj_mins = mins + adjustments.global.negative_black_clip * raw_range;
let adj_maxs = maxs - adjustments.global.negative_white_clip * raw_range;
let range = max(adj_maxs - adj_mins, vec3<f32>(1e-4));
let n = clamp((log_rgb - adj_mins) / range, vec3<f32>(0.0), vec3<f32>(1.0)) * weights;

let k = 4.0 * max(adjustments.global.negative_contrast, 0.1);
let x0 = 0.6 - adjustments.global.negative_exposure * 0.25;
let eps = 1e-6;
let toe_range = max(x0, eps);
let shoulder_range = max(1.0 - x0, eps);

let toe = adjustments.global.negative_toe;
let toe_width = adjustments.global.negative_toe_width;
let shoulder = adjustments.global.negative_shoulder;
let shoulder_width = adjustments.global.negative_shoulder_width;

let diff = n - vec3<f32>(x0);

let t_val = toe_width * (-diff / toe_range - 0.5);
let toe_mask = vec3<f32>(
1.0 / (1.0 + exp(-t_val.r)),
1.0 / (1.0 + exp(-t_val.g)),
1.0 / (1.0 + exp(-t_val.b)),
);

let s_val = shoulder_width * (diff / shoulder_range - 0.5);
let shoulder_mask = vec3<f32>(
1.0 / (1.0 + exp(-s_val.r)),
1.0 / (1.0 + exp(-s_val.g)),
1.0 / (1.0 + exp(-s_val.b)),
);

let toe_offset = toe * toe_mask * 0.25;
let shoulder_offset = shoulder * shoulder_mask * 0.25;
let diff_adj = diff + toe_offset - shoulder_offset;

let damp = toe * toe_mask * 0.5 + shoulder * shoulder_mask * 0.5;
let k_mod = clamp(vec3<f32>(1.0) - damp, vec3<f32>(0.1), vec3<f32>(2.0));

let arg = k * diff_adj * k_mod;
let s = vec3<f32>(
1.0 / (1.0 + exp(-arg.r)),
1.0 / (1.0 + exp(-arg.g)),
1.0 / (1.0 + exp(-arg.b)),
);

let y0 = 1.0 / (1.0 + exp(k * x0));
let y1 = 1.0 / (1.0 + exp(-k * (1.0 - x0)));
let scale = 1.0 / max(y1 - y0, eps);
return clamp((s - y0) * scale, vec3<f32>(0.0), vec3<f32>(1.0));
}

fn rgb_to_hsv(c: vec3<f32>) -> vec3<f32> {
let c_max = max(c.r, max(c.g, c.b));
let c_min = min(c.r, min(c.g, c.b));
Expand Down Expand Up @@ -1439,6 +1532,10 @@ fn main(@builtin(global_invocation_id) id: vec3<u32>) {
initial_linear_rgb = color_from_texture;
}

if (adjustments.global.negative_enabled == 1u) {
initial_linear_rgb = apply_negative_inversion(initial_linear_rgb);
}

var t_exposure = adjustments.global.exposure;
var t_brightness = adjustments.global.brightness;
var t_contrast = adjustments.global.contrast;
Expand Down
28 changes: 7 additions & 21 deletions src/components/modals/NegativeConversionModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -292,27 +292,13 @@ export default function NegativeConversionModal({
>
<Info size={16} className="shrink-0" />
<div className="text-xs text-text-tertiary leading-tight space-y-1">
<Trans i18nKey="modals.negativeConversion.noticeText">
Inversion logic inspired by{' '}
<a
href="https://github.com/marcinz606/NegPy"
target="_blank"
rel="noreferrer"
className="underline hover:text-primary transition-colors"
>
NegPy
</a>{' '}
created by marcinz606 (
<a
href="https://github.com/marcinz606/NegPy/blob/main/LICENSE"
target="_blank"
rel="noreferrer"
className="underline hover:text-primary transition-colors"
>
GPL-3.0
</a>
).
</Trans>
<Trans
i18nKey="modals.negativeConversion.noticeText"
components={[
<a href="https://github.com/marcinz606/NegPy" target="_blank" rel="noreferrer" className="underline hover:text-primary transition-colors" />,
<a href="https://github.com/marcinz606/NegPy/blob/main/LICENSE" target="_blank" rel="noreferrer" className="underline hover:text-primary transition-colors" />,
]}
/>
</div>
</Text>
</div>
Expand Down
Loading