Last active
September 3, 2025 12:31
-
-
Save motebaya/b0567cc8bac93320150ae146419585bc to your computer and use it in GitHub Desktop.
Bulk/single image/s convert to Pink × Green duotone
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
#!/usr/bin/python3 | |
# -*- coding: utf-8 -*- | |
# @gist.github.com/motebaya - 3/9/2025 | |
# rewrite from TS - https://lovable.dev/projects/8354edf5-ed78-4f83-8860-5ac9d2f067ff | |
import numpy as np | |
import os | |
from PIL import Image | |
import sys | |
OPTIMIZED_SHADOW = np.array([22, 80, 39], dtype=np.float32) #165027 | |
OPTIMIZED_HIGHLIGHT = np.array([249, 159, 210], dtype=np.float32) #F99FD2 | |
CLASSIC_SHADOW = np.array([27, 96, 47], dtype=np.float32) #1b602f | |
CLASSIC_HIGHLIGHT = np.array([247, 132, 197], dtype=np.float32) #f784c5 | |
def duotone( | |
imgp: str, | |
classic: bool = False, | |
reverse: bool = False, | |
out: str = "", | |
) -> None: | |
img = Image.open(imgp).convert("RGB") | |
arr = np.asarray(img, dtype=np.float32) | |
# using rec. 709 formula | |
luminance = 0.2126 * arr[...,0] + 0.7152 * arr[...,1] + 0.0722 * arr[...,2] | |
min_l, max_l = luminance.min(), luminance.max() | |
norm = (luminance - min_l) / (max_l - min_l + 1e-6) | |
# contrast | |
norm = np.where( | |
norm < 0.5, | |
np.power(norm * 2, 1.8) / 2, | |
1 - np.power((1 - norm) * 2, 1.8) / 2 | |
) | |
f = "duotone_" | |
if classic: | |
f += "classic" | |
s, h = CLASSIC_SHADOW, CLASSIC_HIGHLIGHT | |
else: | |
s, h = OPTIMIZED_SHADOW, OPTIMIZED_HIGHLIGHT | |
f += "optimized" | |
if reverse: | |
f += "_reverse" | |
s, h = h, s | |
f = f + "_" + os.path.basename(imgp) | |
norm = norm[..., None] | |
Image.fromarray( | |
np.clip( | |
s + norm * (h - s), | |
0, 255 | |
).astype(np.uint8), | |
"RGB" | |
).save(os.path.join(out, f)) | |
print(f" * ok: {os.path.join(out, f)}") | |
return | |
def fromdof(f: str, **kwargs) -> None: | |
if os.path.isfile(f): | |
duotone(f, **kwargs) | |
return | |
if os.path.isdir(f): | |
out = os.path.join(f, "duotone_" + "reverse" if kwargs["reverse"] else "classic" if kwargs["classic"] else "original") | |
os.makedirs(out, exist_ok=True) | |
kwargs['out'] = out | |
for x, file in enumerate(os.listdir(f), 1): | |
print(f"{x:02d} | {file}") | |
duotone( | |
os.path.join(f, file), | |
**kwargs, | |
) | |
return | |
if __name__ == "__main__": | |
arg = sys.argv | |
if len(arg) < 2: | |
print("Usage: python pg_duotone.py <image_path> <mode>") | |
sys.exit(1) | |
mode = arg[2].split(",") if len(arg) > 2 else [] | |
assert all( | |
m in ( | |
"classic", | |
"reverse", | |
) for m in mode), "wrong mode!" | |
fromdof( | |
arg[1], | |
reverse="reverse" in mode, | |
classic="classic" in mode, | |
) | |
Author
motebaya
commented
Sep 3, 2025
Original | Classic | Reverse | Classic & Reverse |
---|---|---|---|
![]() |
![]() |
![]() |
![]() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment