anshitsu 3.2.0__py3-none-win_amd64.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- anshitsu/__init__.py +0 -0
- anshitsu/__version__.py +1 -0
- anshitsu/_native/anshitsu_color.dll +0 -0
- anshitsu/cli.py +193 -0
- anshitsu/main.py +14 -0
- anshitsu/process/__init__.py +0 -0
- anshitsu/process/_native_color.py +168 -0
- anshitsu/process/apocalypse.py +67 -0
- anshitsu/process/ashigara.py +44 -0
- anshitsu/process/brightness.py +17 -0
- anshitsu/process/classic.py +65 -0
- anshitsu/process/color.py +17 -0
- anshitsu/process/color_auto_adjust.py +23 -0
- anshitsu/process/color_stretch.py +23 -0
- anshitsu/process/contrast.py +24 -0
- anshitsu/process/create_alpha_mask.py +28 -0
- anshitsu/process/cross_process.py +76 -0
- anshitsu/process/cyanotype.py +13 -0
- anshitsu/process/grayscale.py +42 -0
- anshitsu/process/invert.py +14 -0
- anshitsu/process/line_drawing.py +22 -0
- anshitsu/process/noise.py +42 -0
- anshitsu/process/orthochromatic.py +34 -0
- anshitsu/process/output_rgb.py +13 -0
- anshitsu/process/posterize.py +15 -0
- anshitsu/process/processor.py +264 -0
- anshitsu/process/put_alpha_mask.py +15 -0
- anshitsu/process/remove_alpha.py +33 -0
- anshitsu/process/rochester.py +35 -0
- anshitsu/process/roppongi.py +92 -0
- anshitsu/process/sepia.py +20 -0
- anshitsu/process/sharpness.py +17 -0
- anshitsu/process/ultramarine.py +55 -0
- anshitsu/process/vignette.py +37 -0
- anshitsu-3.2.0.dist-info/LICENSE +21 -0
- anshitsu-3.2.0.dist-info/METADATA +391 -0
- anshitsu-3.2.0.dist-info/RECORD +39 -0
- anshitsu-3.2.0.dist-info/WHEEL +4 -0
- anshitsu-3.2.0.dist-info/entry_points.txt +3 -0
anshitsu/__init__.py
ADDED
|
File without changes
|
anshitsu/__version__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
version: str = "v2.0.2"
|
|
Binary file
|
anshitsu/cli.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
import datetime
|
|
2
|
+
import glob
|
|
3
|
+
import os
|
|
4
|
+
import os.path
|
|
5
|
+
import re
|
|
6
|
+
from typing import Optional
|
|
7
|
+
|
|
8
|
+
import fire
|
|
9
|
+
import fire.core
|
|
10
|
+
from PIL import Image, UnidentifiedImageError
|
|
11
|
+
|
|
12
|
+
from anshitsu.__version__ import version as __version__
|
|
13
|
+
from anshitsu.process.processor import Processor
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def cli(
|
|
17
|
+
path: Optional[str] = None,
|
|
18
|
+
keep_alpha: bool = False,
|
|
19
|
+
colorautoadjust: bool = False,
|
|
20
|
+
colorstretch: bool = False,
|
|
21
|
+
grayscale: bool = False,
|
|
22
|
+
orthochromatic: bool = False,
|
|
23
|
+
invert: bool = False,
|
|
24
|
+
color: Optional[float] = None,
|
|
25
|
+
brightness: Optional[float] = None,
|
|
26
|
+
sharpness: Optional[float] = None,
|
|
27
|
+
contrast: Optional[float] = None,
|
|
28
|
+
tosaka: Optional[float] = None,
|
|
29
|
+
outputrgb: bool = False,
|
|
30
|
+
sepia: bool = False,
|
|
31
|
+
cyanotype: bool = False,
|
|
32
|
+
rochester: bool = False,
|
|
33
|
+
ashigara: bool = False,
|
|
34
|
+
crossprocess: bool = False,
|
|
35
|
+
apocalypse: bool = False,
|
|
36
|
+
ultramarine: bool = False,
|
|
37
|
+
roppongi: bool = False,
|
|
38
|
+
classic: bool = False,
|
|
39
|
+
noise: Optional[float] = None,
|
|
40
|
+
overwrite: bool = False,
|
|
41
|
+
version: bool = False,
|
|
42
|
+
line_drawing: bool = False,
|
|
43
|
+
posterize: Optional[int] = None,
|
|
44
|
+
vignette: Optional[float] = None,
|
|
45
|
+
) -> str:
|
|
46
|
+
"""
|
|
47
|
+
Process Runnner for Command Line Interface
|
|
48
|
+
|
|
49
|
+
This utility converts the colors of images such as photos.
|
|
50
|
+
|
|
51
|
+
If you specify a directory path, it will convert
|
|
52
|
+
the image files in the specified directory.
|
|
53
|
+
If you specify a file path, it will convert the specified file.
|
|
54
|
+
If you specify an option, the specified conversion will be performed.
|
|
55
|
+
|
|
56
|
+
Tosaka mode is named after Tosaka-senpai's "Tri-X de banzen"
|
|
57
|
+
line from "Kyūkyoku Chōjin R". It aims for a grainy
|
|
58
|
+
black-and-white photo look similar to Kodak Tri-X film.
|
|
59
|
+
This mode converts the image to grayscale and adjusts contrast.
|
|
60
|
+
Use floating-point numbers; values around 2.4 usually work well.
|
|
61
|
+
|
|
62
|
+
Args:
|
|
63
|
+
path (Optional[str], optional): Directory or file path. Defaults to None.
|
|
64
|
+
keep_alpha (bool, optional): Keep the alpha channel. Defaults to False.
|
|
65
|
+
colorautoadjust (bool, optional): Correct colors using Automatic Color Equalization. Defaults to False.
|
|
66
|
+
colorstretch (bool, optional): Apply gray-world white balance and color stretching. Defaults to False.
|
|
67
|
+
grayscale (bool, optional): Convert to grayscale. Defaults to False.
|
|
68
|
+
orthochromatic (bool, optional): Convert to orthochromatic-style grayscale. Defaults to False.
|
|
69
|
+
invert (bool, optional): Invert image colors. Defaults to False.
|
|
70
|
+
color (Optional[float], optional): Adjust color. Defaults to None.
|
|
71
|
+
brightness (Optional[float], optional): Adjust brightness. Defaults to None.
|
|
72
|
+
sharpness (Optional[float], optional): Adjust sharpness. Defaults to None.
|
|
73
|
+
contrast (Optional[float], optional): Adjust contrast. Defaults to None.
|
|
74
|
+
tosaka (Optional[float], optional): Use Tosaka mode. Defaults to None.
|
|
75
|
+
outputrgb (bool, optional): Convert a monochrome image to RGB. Defaults to False.
|
|
76
|
+
sepia (bool, optional): Colorize a monochrome image with sepia tones. Defaults to False.
|
|
77
|
+
cyanotype (bool, optional): Colorize a monochrome image with cyanotype-like Prussian blue. Defaults to False.
|
|
78
|
+
rochester (bool, optional): Apply a warm color grade inspired by Kodak PORTRA 400. Defaults to False.
|
|
79
|
+
ashigara (bool, optional): Apply a vivid color grade inspired by Fujifilm Velvia 100. Defaults to False.
|
|
80
|
+
crossprocess (bool, optional): Apply a random cross-process-style color grade. Defaults to False.
|
|
81
|
+
apocalypse (bool, optional): Apply a red-orange Velvia 100 cross-process preset. Defaults to False.
|
|
82
|
+
ultramarine (bool, optional): Apply a blue-forward color grade inspired by Kodak Ultramax. Defaults to False.
|
|
83
|
+
roppongi (bool, optional): Apply a smooth fine-grain monochrome preset. Defaults to False.
|
|
84
|
+
classic (bool, optional): Apply a classic high-acutance monochrome preset. Defaults to False.
|
|
85
|
+
noise (Optional[float], optional): Add Gaussian noise. Defaults to None.
|
|
86
|
+
overwrite (bool, optional): Overwrite original files. Defaults to False.
|
|
87
|
+
version (bool, optional): Show version. Defaults to False.
|
|
88
|
+
line_drawing (bool, optional): Convert to a line drawing. Defaults to False.
|
|
89
|
+
posterize (Optional[int], optional): Posterize the image. Defaults to None.
|
|
90
|
+
vignette (Optional[float], optional): Darken image edges with a radial vignette. Defaults to None.
|
|
91
|
+
|
|
92
|
+
Raises:
|
|
93
|
+
fire.core.FireError: Error that occurs when the specified string is not a path.
|
|
94
|
+
|
|
95
|
+
Returns:
|
|
96
|
+
str: Message.
|
|
97
|
+
"""
|
|
98
|
+
if version:
|
|
99
|
+
return "Anshitsu version {}".format(__version__)
|
|
100
|
+
if path is None:
|
|
101
|
+
raise fire.core.FireError("No path specified!")
|
|
102
|
+
types = ("*.jpg", "*.JPG", "*.jpeg", "*.JPEG", "*.png", "*.PNG")
|
|
103
|
+
files_glob = []
|
|
104
|
+
return_path = ""
|
|
105
|
+
now_s = datetime.datetime.now()
|
|
106
|
+
output_dir = "anshitsu_out"
|
|
107
|
+
original_dir = "anshitsu_orig"
|
|
108
|
+
if os.path.isdir(path):
|
|
109
|
+
for type in types:
|
|
110
|
+
files_glob.extend(glob.glob(os.path.join(path, "**", type), recursive=True))
|
|
111
|
+
files_glob = [file for file in files_glob if not file.__contains__(output_dir)]
|
|
112
|
+
return_path = path
|
|
113
|
+
|
|
114
|
+
if len(files_glob) == 0:
|
|
115
|
+
raise fire.core.FireError(
|
|
116
|
+
"There are no JPEG or PNG files in this directory."
|
|
117
|
+
)
|
|
118
|
+
elif os.path.isfile(path):
|
|
119
|
+
files_glob.extend(glob.glob(path))
|
|
120
|
+
return_path = os.path.abspath(os.path.join(path, os.pardir))
|
|
121
|
+
else:
|
|
122
|
+
raise fire.core.FireError("A non-path string was passed.")
|
|
123
|
+
if overwrite is True:
|
|
124
|
+
os.makedirs(os.path.join(return_path, original_dir))
|
|
125
|
+
for i, file in enumerate(files_glob):
|
|
126
|
+
try:
|
|
127
|
+
image = Image.open(file)
|
|
128
|
+
except UnidentifiedImageError as e:
|
|
129
|
+
raise fire.core.FireError(e)
|
|
130
|
+
exif = image.getexif()
|
|
131
|
+
original_filename: str = os.path.split(file)[1]
|
|
132
|
+
extension = original_filename.split(".")[-1]
|
|
133
|
+
timestamp = now_s.strftime("%Y-%m-%d_%H-%M-%S")
|
|
134
|
+
if overwrite is True:
|
|
135
|
+
backup_filename = original_filename
|
|
136
|
+
image.save(os.path.join(return_path, original_dir, backup_filename))
|
|
137
|
+
filename = os.path.join(
|
|
138
|
+
return_path, re.sub(r"\.[^.]+$", "", original_filename) + ".png"
|
|
139
|
+
)
|
|
140
|
+
remove_file_list = [".jpg", ".JPG", ".jpeg", ".JPEG", ".PNG"]
|
|
141
|
+
for remove_file in remove_file_list:
|
|
142
|
+
remove_file_name = (
|
|
143
|
+
re.sub(r"\.[^.]+$", "", original_filename) + remove_file
|
|
144
|
+
)
|
|
145
|
+
remove_file_path = os.path.join(return_path, remove_file_name)
|
|
146
|
+
if os.path.isfile(remove_file_path):
|
|
147
|
+
os.remove(remove_file_path)
|
|
148
|
+
else:
|
|
149
|
+
filename = os.path.join(
|
|
150
|
+
return_path,
|
|
151
|
+
output_dir,
|
|
152
|
+
re.sub(r"\.[^.]+$", "_", original_filename)
|
|
153
|
+
+ "_{0}_converted_at_{1}.png".format(extension, timestamp),
|
|
154
|
+
)
|
|
155
|
+
psr = Processor(
|
|
156
|
+
image=image,
|
|
157
|
+
keep_alpha=keep_alpha,
|
|
158
|
+
colorautoadjust=colorautoadjust,
|
|
159
|
+
colorstretch=colorstretch,
|
|
160
|
+
grayscale=grayscale,
|
|
161
|
+
orthochromatic=orthochromatic,
|
|
162
|
+
color=color,
|
|
163
|
+
contrast=contrast,
|
|
164
|
+
brightness=brightness,
|
|
165
|
+
sharpness=sharpness,
|
|
166
|
+
invert=invert,
|
|
167
|
+
tosaka=tosaka,
|
|
168
|
+
outputrgb=outputrgb,
|
|
169
|
+
cyanotype=cyanotype,
|
|
170
|
+
sepia=sepia,
|
|
171
|
+
rochester=rochester,
|
|
172
|
+
ashigara=ashigara,
|
|
173
|
+
crossprocess=crossprocess,
|
|
174
|
+
apocalypse=apocalypse,
|
|
175
|
+
ultramarine=ultramarine,
|
|
176
|
+
roppongi=roppongi,
|
|
177
|
+
classic=classic,
|
|
178
|
+
noise=noise,
|
|
179
|
+
line_drawing=line_drawing,
|
|
180
|
+
posterize=posterize,
|
|
181
|
+
vignette=vignette,
|
|
182
|
+
)
|
|
183
|
+
saved_image = psr.process()
|
|
184
|
+
os.makedirs(os.path.join(return_path, output_dir), exist_ok=True)
|
|
185
|
+
saved_image.save(
|
|
186
|
+
filename,
|
|
187
|
+
quality=100, # Specify 100 as the highest image quality
|
|
188
|
+
subsampling=0,
|
|
189
|
+
exif=exif,
|
|
190
|
+
)
|
|
191
|
+
print("{0}/{1} done!".format((i + 1), str(len(files_glob))))
|
|
192
|
+
|
|
193
|
+
return "The cli was completed successfully."
|
anshitsu/main.py
ADDED
|
File without changes
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
import ctypes
|
|
2
|
+
import os
|
|
3
|
+
import platform
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Optional
|
|
6
|
+
|
|
7
|
+
from PIL import Image
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def automatic_color_equalization(
|
|
11
|
+
image: Image,
|
|
12
|
+
samples: int = 500,
|
|
13
|
+
slope: float = 10.0,
|
|
14
|
+
limit: float = 1000.0,
|
|
15
|
+
) -> Optional[Image]:
|
|
16
|
+
"""
|
|
17
|
+
Apply the Rust Automatic Color Equalization backend when available.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
image: RGB image to process.
|
|
21
|
+
samples: Maximum number of spatial samples used for ACE comparison.
|
|
22
|
+
slope: Slope applied to channel differences before clipping.
|
|
23
|
+
limit: Maximum absolute contrast contribution.
|
|
24
|
+
|
|
25
|
+
Returns:
|
|
26
|
+
Processed RGB image, or None when the native backend is unavailable.
|
|
27
|
+
"""
|
|
28
|
+
library = _load_library()
|
|
29
|
+
if library is None:
|
|
30
|
+
return None
|
|
31
|
+
|
|
32
|
+
rgb_image = image.convert("RGB")
|
|
33
|
+
width, height = rgb_image.size
|
|
34
|
+
buffer = bytearray(rgb_image.tobytes())
|
|
35
|
+
array_type = ctypes.c_ubyte * len(buffer)
|
|
36
|
+
c_buffer = array_type.from_buffer(buffer)
|
|
37
|
+
|
|
38
|
+
result = library.anshitsu_ace_rgb(
|
|
39
|
+
c_buffer,
|
|
40
|
+
ctypes.c_size_t(len(buffer)),
|
|
41
|
+
ctypes.c_size_t(width),
|
|
42
|
+
ctypes.c_size_t(height),
|
|
43
|
+
ctypes.c_size_t(samples),
|
|
44
|
+
ctypes.c_float(slope),
|
|
45
|
+
ctypes.c_float(limit),
|
|
46
|
+
)
|
|
47
|
+
if result != 0:
|
|
48
|
+
return None
|
|
49
|
+
|
|
50
|
+
return Image.frombytes("RGB", rgb_image.size, bytes(buffer))
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def color_stretch(image: Image) -> Optional[Image]:
|
|
54
|
+
"""
|
|
55
|
+
Apply the Rust gray-world and color stretching backend when available.
|
|
56
|
+
|
|
57
|
+
Args:
|
|
58
|
+
image: RGB image to process.
|
|
59
|
+
|
|
60
|
+
Returns:
|
|
61
|
+
Processed RGB image, or None when the native backend is unavailable.
|
|
62
|
+
"""
|
|
63
|
+
library = _load_library()
|
|
64
|
+
if library is None:
|
|
65
|
+
return None
|
|
66
|
+
|
|
67
|
+
rgb_image = image.convert("RGB")
|
|
68
|
+
width, height = rgb_image.size
|
|
69
|
+
buffer = bytearray(rgb_image.tobytes())
|
|
70
|
+
array_type = ctypes.c_ubyte * len(buffer)
|
|
71
|
+
c_buffer = array_type.from_buffer(buffer)
|
|
72
|
+
|
|
73
|
+
result = library.anshitsu_color_stretch_rgb(
|
|
74
|
+
c_buffer,
|
|
75
|
+
ctypes.c_size_t(len(buffer)),
|
|
76
|
+
ctypes.c_size_t(width),
|
|
77
|
+
ctypes.c_size_t(height),
|
|
78
|
+
)
|
|
79
|
+
if result != 0:
|
|
80
|
+
return None
|
|
81
|
+
|
|
82
|
+
return Image.frombytes("RGB", rgb_image.size, bytes(buffer))
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _load_library() -> Optional[ctypes.CDLL]:
|
|
86
|
+
path = _find_library_path()
|
|
87
|
+
if path is None:
|
|
88
|
+
return None
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
library = ctypes.CDLL(str(path))
|
|
92
|
+
except OSError:
|
|
93
|
+
return None
|
|
94
|
+
|
|
95
|
+
try:
|
|
96
|
+
library.anshitsu_ace_rgb.argtypes = [
|
|
97
|
+
ctypes.POINTER(ctypes.c_ubyte),
|
|
98
|
+
ctypes.c_size_t,
|
|
99
|
+
ctypes.c_size_t,
|
|
100
|
+
ctypes.c_size_t,
|
|
101
|
+
ctypes.c_size_t,
|
|
102
|
+
ctypes.c_float,
|
|
103
|
+
ctypes.c_float,
|
|
104
|
+
]
|
|
105
|
+
library.anshitsu_ace_rgb.restype = ctypes.c_int
|
|
106
|
+
library.anshitsu_color_stretch_rgb.argtypes = [
|
|
107
|
+
ctypes.POINTER(ctypes.c_ubyte),
|
|
108
|
+
ctypes.c_size_t,
|
|
109
|
+
ctypes.c_size_t,
|
|
110
|
+
ctypes.c_size_t,
|
|
111
|
+
]
|
|
112
|
+
library.anshitsu_color_stretch_rgb.restype = ctypes.c_int
|
|
113
|
+
except AttributeError:
|
|
114
|
+
return None
|
|
115
|
+
return library
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _find_library_path() -> Optional[Path]:
|
|
119
|
+
env_path = os.environ.get("ANSHITSU_COLOR_LIB")
|
|
120
|
+
if env_path:
|
|
121
|
+
path = Path(env_path)
|
|
122
|
+
if path.exists():
|
|
123
|
+
return path
|
|
124
|
+
|
|
125
|
+
library_name = _library_name()
|
|
126
|
+
candidates = [
|
|
127
|
+
Path(__file__).resolve().parents[1] / "_native" / library_name,
|
|
128
|
+
]
|
|
129
|
+
repository_root = _repository_root()
|
|
130
|
+
if repository_root is not None:
|
|
131
|
+
candidates.append(
|
|
132
|
+
repository_root
|
|
133
|
+
/ "native"
|
|
134
|
+
/ "anshitsu-color"
|
|
135
|
+
/ "target"
|
|
136
|
+
/ "release"
|
|
137
|
+
/ library_name
|
|
138
|
+
)
|
|
139
|
+
candidates.append(
|
|
140
|
+
repository_root
|
|
141
|
+
/ "native"
|
|
142
|
+
/ "anshitsu-color"
|
|
143
|
+
/ "target"
|
|
144
|
+
/ "debug"
|
|
145
|
+
/ library_name
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
for candidate in candidates:
|
|
149
|
+
if candidate.exists():
|
|
150
|
+
return candidate
|
|
151
|
+
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _library_name() -> str:
|
|
156
|
+
system = platform.system()
|
|
157
|
+
if system == "Darwin":
|
|
158
|
+
return "libanshitsu_color.dylib"
|
|
159
|
+
if system == "Windows":
|
|
160
|
+
return "anshitsu_color.dll"
|
|
161
|
+
return "libanshitsu_color.so"
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _repository_root() -> Optional[Path]:
|
|
165
|
+
for parent in Path(__file__).resolve().parents:
|
|
166
|
+
if (parent / "pyproject.toml").exists():
|
|
167
|
+
return parent
|
|
168
|
+
return None
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
from typing import Optional
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
from PIL import Image
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _soft_clip_highlights(image_array: np.ndarray) -> np.ndarray:
|
|
8
|
+
"""
|
|
9
|
+
Compress highlights after the warm cross-process color shift.
|
|
10
|
+
|
|
11
|
+
Parameters:
|
|
12
|
+
image_array: normalized RGB image array
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
np.ndarray: highlight-compressed normalized RGB image array.
|
|
16
|
+
"""
|
|
17
|
+
highlight_start = 0.80
|
|
18
|
+
compression = 2.6
|
|
19
|
+
highlights = np.maximum(image_array - highlight_start, 0.0)
|
|
20
|
+
compressed = highlights / (1.0 + highlights * compression)
|
|
21
|
+
return np.minimum(image_array, highlight_start + compressed)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def apocalypse(image: Image, seed: Optional[int] = None) -> Image:
|
|
25
|
+
"""
|
|
26
|
+
Apply a red-orange cross-process preset inspired by Velvia 100.
|
|
27
|
+
|
|
28
|
+
This preset leans into the orange-to-red color cast often associated with
|
|
29
|
+
cross-processing Velvia 100. It aims for a dramatic end-of-days palette:
|
|
30
|
+
hot reds, heavy oranges, suppressed blues, strong saturation, and controlled
|
|
31
|
+
highlights. The strength of the red shift varies slightly each time it runs;
|
|
32
|
+
pass a seed to make the result reproducible for tests or comparisons.
|
|
33
|
+
|
|
34
|
+
Parameters:
|
|
35
|
+
image: Image
|
|
36
|
+
seed: optional random seed
|
|
37
|
+
|
|
38
|
+
Returns:
|
|
39
|
+
Image: processed RGB image.
|
|
40
|
+
"""
|
|
41
|
+
rng = np.random.default_rng(seed)
|
|
42
|
+
red_shift = float(rng.uniform(0.75, 1.25))
|
|
43
|
+
image_array = np.array(image.convert("RGB"), dtype="float32") / 255.0
|
|
44
|
+
|
|
45
|
+
image_array = np.power(np.clip(image_array, 0.0, 1.0), (0.88, 0.98, 1.18))
|
|
46
|
+
red = image_array[:, :, 0] * (1.18 + 0.10 * red_shift) + (
|
|
47
|
+
0.035 + 0.020 * red_shift
|
|
48
|
+
)
|
|
49
|
+
green = image_array[:, :, 1] * (0.98 - 0.035 * red_shift) + 0.020
|
|
50
|
+
blue = image_array[:, :, 2] * (0.78 - 0.12 * red_shift) - (
|
|
51
|
+
0.010 + 0.010 * red_shift
|
|
52
|
+
)
|
|
53
|
+
image_array = np.stack((red, green, blue), axis=2)
|
|
54
|
+
image_array = (image_array - 0.5) * (1.12 + 0.10 * red_shift) + 0.5
|
|
55
|
+
|
|
56
|
+
luminance = (
|
|
57
|
+
image_array[:, :, 0] * 0.299
|
|
58
|
+
+ image_array[:, :, 1] * 0.587
|
|
59
|
+
+ image_array[:, :, 2] * 0.114
|
|
60
|
+
)
|
|
61
|
+
image_array = luminance[:, :, np.newaxis] + (
|
|
62
|
+
image_array - luminance[:, :, np.newaxis]
|
|
63
|
+
) * (1.18 + 0.20 * red_shift)
|
|
64
|
+
|
|
65
|
+
image_array = _soft_clip_highlights(image_array)
|
|
66
|
+
image_array = np.clip(image_array, 0.0, 0.992)
|
|
67
|
+
return Image.fromarray((image_array * 255).astype("uint8"))
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from PIL import Image
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _soft_clip_highlights(image_array: np.ndarray) -> np.ndarray:
|
|
6
|
+
highlight_start = 0.78
|
|
7
|
+
compression = 3.8
|
|
8
|
+
highlights = np.maximum(image_array - highlight_start, 0.0)
|
|
9
|
+
compressed = highlights / (1.0 + highlights * compression)
|
|
10
|
+
return np.minimum(image_array, highlight_start + compressed)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def ashigara(image: Image) -> Image:
|
|
14
|
+
"""
|
|
15
|
+
Apply a vivid, high-contrast color grade inspired by Fujifilm Velvia 100.
|
|
16
|
+
|
|
17
|
+
Returns:
|
|
18
|
+
Image: processed image.
|
|
19
|
+
"""
|
|
20
|
+
rgb_image = image.convert("RGB")
|
|
21
|
+
image_array = np.array(rgb_image, dtype="float32") / 255.0
|
|
22
|
+
|
|
23
|
+
# Increase contrast with a restrained S-curve.
|
|
24
|
+
image_array = 3 * image_array**2 - 2 * image_array**3
|
|
25
|
+
image_array = (image_array - 0.5) * 1.035 + 0.5
|
|
26
|
+
|
|
27
|
+
red = image_array[:, :, 0] * 1.025
|
|
28
|
+
green = image_array[:, :, 1] * 1.02
|
|
29
|
+
blue = image_array[:, :, 2] * 1.03
|
|
30
|
+
image_array = np.stack((red, green, blue), axis=2)
|
|
31
|
+
|
|
32
|
+
luminance = (
|
|
33
|
+
image_array[:, :, 0] * 0.299
|
|
34
|
+
+ image_array[:, :, 1] * 0.587
|
|
35
|
+
+ image_array[:, :, 2] * 0.114
|
|
36
|
+
)
|
|
37
|
+
saturation = 1.14
|
|
38
|
+
image_array = luminance[:, :, np.newaxis] + (
|
|
39
|
+
image_array - luminance[:, :, np.newaxis]
|
|
40
|
+
) * saturation
|
|
41
|
+
|
|
42
|
+
image_array = _soft_clip_highlights(image_array)
|
|
43
|
+
image_array = np.clip(image_array, 0.0, 1.0)
|
|
44
|
+
return Image.fromarray((image_array * 255).astype("uint8"))
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from PIL import Image, ImageEnhance
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def brightness(image: Image, brightness: float) -> Image:
|
|
5
|
+
"""
|
|
6
|
+
Adjust image brightness.
|
|
7
|
+
|
|
8
|
+
Parameters:
|
|
9
|
+
image: Image
|
|
10
|
+
brightness: enhancement factor
|
|
11
|
+
|
|
12
|
+
Returns:
|
|
13
|
+
Image: processed image.
|
|
14
|
+
"""
|
|
15
|
+
enhancer = ImageEnhance.Brightness(image)
|
|
16
|
+
image = enhancer.enhance(brightness)
|
|
17
|
+
return image
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
from PIL import Image, ImageChops, ImageFilter
|
|
3
|
+
|
|
4
|
+
from anshitsu.process.grayscale import grayscale
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def _classic_tone_curve(gray: np.ndarray) -> np.ndarray:
|
|
8
|
+
"""
|
|
9
|
+
Apply a classic high-acutance monochrome tone curve.
|
|
10
|
+
|
|
11
|
+
Parameters:
|
|
12
|
+
gray: normalized grayscale image array
|
|
13
|
+
|
|
14
|
+
Returns:
|
|
15
|
+
np.ndarray: tone-adjusted normalized image array.
|
|
16
|
+
"""
|
|
17
|
+
shadows = np.clip((gray - 0.015) / 0.985, 0.0, 1.0)
|
|
18
|
+
contrast = 3 * shadows**2 - 2 * shadows**3
|
|
19
|
+
contrast = (contrast - 0.5) * 1.12 + 0.5
|
|
20
|
+
|
|
21
|
+
highlight_start = 0.86
|
|
22
|
+
highlights = np.maximum(contrast - highlight_start, 0.0)
|
|
23
|
+
compressed = highlights / (1.0 + highlights * 1.6)
|
|
24
|
+
return np.minimum(contrast, highlight_start + compressed)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _add_classic_grain(image: Image, amount: float) -> Image:
|
|
28
|
+
"""
|
|
29
|
+
Add visible but restrained monochrome grain to an L image.
|
|
30
|
+
|
|
31
|
+
Parameters:
|
|
32
|
+
image: L mode image
|
|
33
|
+
amount: grain strength
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
Image: grain-adjusted L image.
|
|
37
|
+
"""
|
|
38
|
+
noise_image = Image.effect_noise(image.size, amount)
|
|
39
|
+
table = [x * 2 for x in range(256)]
|
|
40
|
+
return ImageChops.multiply(image, noise_image).point(table)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def classic(image: Image) -> Image:
|
|
44
|
+
"""
|
|
45
|
+
Apply a classic monochrome preset inspired by Kodak TRI-X in Rodinal.
|
|
46
|
+
|
|
47
|
+
The preset converts the image to L mode, adds a firm but not extreme tone
|
|
48
|
+
curve, protects the brightest highlights, adds visible fine grain, and
|
|
49
|
+
applies mild sharpening. It is intended to be less aggressive than Tosaka
|
|
50
|
+
mode while keeping the traditional high-acutance feel of TRI-X developed in
|
|
51
|
+
Rodinal.
|
|
52
|
+
|
|
53
|
+
Parameters:
|
|
54
|
+
image: Image
|
|
55
|
+
|
|
56
|
+
Returns:
|
|
57
|
+
Image: processed image in L mode.
|
|
58
|
+
"""
|
|
59
|
+
monochrome = grayscale(image)
|
|
60
|
+
gray = np.array(monochrome, dtype="float32") / 255.0
|
|
61
|
+
toned = np.clip(_classic_tone_curve(gray), 0.0, 1.0)
|
|
62
|
+
processed = Image.fromarray((toned * 255).astype("uint8"))
|
|
63
|
+
processed = _add_classic_grain(processed, 7.0)
|
|
64
|
+
processed = processed.filter(ImageFilter.UnsharpMask(radius=1.0, percent=70, threshold=2))
|
|
65
|
+
return processed.point(lambda value: min(value, 252))
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from PIL import Image, ImageEnhance
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def color(image: Image, color: float) -> Image:
|
|
5
|
+
"""
|
|
6
|
+
Adjust image color.
|
|
7
|
+
|
|
8
|
+
Parameters:
|
|
9
|
+
image: Image
|
|
10
|
+
color: enhancement factor
|
|
11
|
+
|
|
12
|
+
Returns:
|
|
13
|
+
Image: processed image.
|
|
14
|
+
"""
|
|
15
|
+
enhancer = ImageEnhance.Contrast(image)
|
|
16
|
+
image = enhancer.enhance(color)
|
|
17
|
+
return image
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import colorcorrect.algorithm as cca
|
|
2
|
+
from PIL import Image
|
|
3
|
+
from colorcorrect.util import to_pil, from_pil
|
|
4
|
+
|
|
5
|
+
from anshitsu.process import _native_color
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def color_auto_adjust(image: Image) -> Image:
|
|
9
|
+
"""
|
|
10
|
+
Correct colors using the Automatic Color Equalization algorithm.
|
|
11
|
+
|
|
12
|
+
This process is based on the 2002 paper on Automatic Color Equalization
|
|
13
|
+
by Carlo Gatta and coauthors.
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
Image: processed image.
|
|
17
|
+
"""
|
|
18
|
+
if image.mode == "L":
|
|
19
|
+
return image
|
|
20
|
+
native_image = _native_color.automatic_color_equalization(image)
|
|
21
|
+
if native_image is not None:
|
|
22
|
+
return native_image
|
|
23
|
+
return to_pil(cca.automatic_color_equalization(from_pil(image)))
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import colorcorrect.algorithm as cca
|
|
2
|
+
from PIL import Image
|
|
3
|
+
from colorcorrect.util import to_pil, from_pil
|
|
4
|
+
|
|
5
|
+
from anshitsu.process import _native_color
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def color_stretch(image: Image) -> Image:
|
|
9
|
+
"""
|
|
10
|
+
Apply gray-world white balance and color stretching.
|
|
11
|
+
|
|
12
|
+
Parameters:
|
|
13
|
+
image: Image
|
|
14
|
+
|
|
15
|
+
Returns:
|
|
16
|
+
Image: processed image.
|
|
17
|
+
"""
|
|
18
|
+
if image.mode == "L":
|
|
19
|
+
return image
|
|
20
|
+
native_image = _native_color.color_stretch(image)
|
|
21
|
+
if native_image is not None:
|
|
22
|
+
return native_image
|
|
23
|
+
return to_pil(cca.stretch(cca.grey_world(from_pil(image))))
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
from PIL import Image, ImageEnhance
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
def contrast(image: Image, contrast: float) -> Image:
|
|
5
|
+
"""
|
|
6
|
+
Adjust image contrast.
|
|
7
|
+
|
|
8
|
+
Used by Tosaka mode.
|
|
9
|
+
|
|
10
|
+
Tosaka mode is named after Tosaka-senpai's "Tri-X de banzen"
|
|
11
|
+
line from "Kyūkyoku Chōjin R". It aims for a grainy
|
|
12
|
+
black-and-white photo look similar to Kodak Tri-X film.
|
|
13
|
+
Values around 2.4 usually work well.
|
|
14
|
+
|
|
15
|
+
Parameters:
|
|
16
|
+
image: Image
|
|
17
|
+
contrast: enhancement factor
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
Image: processed image.
|
|
21
|
+
"""
|
|
22
|
+
enhancer = ImageEnhance.Contrast(image)
|
|
23
|
+
image = enhancer.enhance(contrast)
|
|
24
|
+
return image
|