pixedit 0.1.1__py3-none-any.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.
- pixedit/__init__.py +10 -0
- pixedit/__main__.py +10 -0
- pixedit/adjust.py +175 -0
- pixedit/app.py +63 -0
- pixedit/cli.py +135 -0
- pixedit/composite.py +212 -0
- pixedit/factory.py +72 -0
- pixedit/geom.py +646 -0
- pixedit/io.py +260 -0
- pixedit/model.py +534 -0
- pixedit/output_folder.py +306 -0
- pixedit/paint.py +518 -0
- pixedit/pixels.py +127 -0
- pixedit/plugins/__init__.py +6 -0
- pixedit/plugins/api.py +80 -0
- pixedit/plugins/examples/__init__.py +1 -0
- pixedit/plugins/examples/basic.py +135 -0
- pixedit/plugins/loader.py +98 -0
- pixedit/resources/about.md +25 -0
- pixedit/resources/icons/brush.svg +8 -0
- pixedit/resources/icons/close.svg +4 -0
- pixedit/resources/icons/color.svg +6 -0
- pixedit/resources/icons/crop.svg +5 -0
- pixedit/resources/icons/eraser.svg +5 -0
- pixedit/resources/icons/export.svg +5 -0
- pixedit/resources/icons/fill.svg +5 -0
- pixedit/resources/icons/folder.svg +6 -0
- pixedit/resources/icons/hand.svg +3 -0
- pixedit/resources/icons/marker.svg +4 -0
- pixedit/resources/icons/mask.svg +7 -0
- pixedit/resources/icons/new.svg +4 -0
- pixedit/resources/icons/open.svg +4 -0
- pixedit/resources/icons/place.svg +6 -0
- pixedit/resources/icons/redo.svg +4 -0
- pixedit/resources/icons/save-as.svg +6 -0
- pixedit/resources/icons/save.svg +5 -0
- pixedit/resources/icons/select.svg +4 -0
- pixedit/resources/icons/shape.svg +4 -0
- pixedit/resources/icons/text.svg +4 -0
- pixedit/resources/icons/transform.svg +4 -0
- pixedit/resources/icons/undo.svg +4 -0
- pixedit/resources/icons/zoom.svg +5 -0
- pixedit/resources/style.qss +196 -0
- pixedit/session.py +1027 -0
- pixedit/textutil.py +199 -0
- pixedit/tools/__init__.py +5 -0
- pixedit/tools/base.py +109 -0
- pixedit/tools/builtins.py +1146 -0
- pixedit/ui/__init__.py +1 -0
- pixedit/ui/canvas.py +548 -0
- pixedit/ui/dialogs.py +324 -0
- pixedit/ui/icons.py +23 -0
- pixedit/ui/main_window.py +971 -0
- pixedit/ui/palette.py +410 -0
- pixedit/ui/panels.py +903 -0
- pixedit-0.1.1.dist-info/METADATA +179 -0
- pixedit-0.1.1.dist-info/RECORD +61 -0
- pixedit-0.1.1.dist-info/WHEEL +5 -0
- pixedit-0.1.1.dist-info/entry_points.txt +5 -0
- pixedit-0.1.1.dist-info/licenses/LICENSE +21 -0
- pixedit-0.1.1.dist-info/top_level.txt +1 -0
pixedit/__init__.py
ADDED
pixedit/__main__.py
ADDED
pixedit/adjust.py
ADDED
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
"""Color adjustments and destructive filters.
|
|
2
|
+
|
|
3
|
+
Each function returns a new image. Callers intersect with the selection and
|
|
4
|
+
wrap the result in ``MutateLayerCommand``.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
from PIL import Image, ImageFilter
|
|
11
|
+
|
|
12
|
+
from pixedit.pixels import gaussian_image, pixelate_image
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _rgba(image: Image.Image) -> np.ndarray:
|
|
16
|
+
return np.array(image.convert("RGBA"))
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _from_rgb(arr: np.ndarray) -> Image.Image:
|
|
20
|
+
return Image.fromarray(np.ascontiguousarray(np.clip(np.rint(arr), 0, 255).astype(np.uint8)), "RGBA")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def apply_masked(before: Image.Image, after: Image.Image, selection: Image.Image | None) -> Image.Image:
|
|
24
|
+
"""Keep ``before`` outside the selection and ``after`` inside it."""
|
|
25
|
+
if selection is None:
|
|
26
|
+
return after
|
|
27
|
+
original = _rgba(before).astype(np.float32)
|
|
28
|
+
edited = _rgba(after).astype(np.float32)
|
|
29
|
+
mask = np.asarray(selection.convert("L")).astype(np.float32) / 255.0
|
|
30
|
+
if mask.shape != original.shape[:2]:
|
|
31
|
+
return after
|
|
32
|
+
mix = mask[..., None]
|
|
33
|
+
out = edited * mix + original * (1.0 - mix)
|
|
34
|
+
return _from_rgb(out)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def brightness_contrast(image: Image.Image, brightness: float, contrast: float) -> Image.Image:
|
|
38
|
+
"""``brightness`` and ``contrast`` are each -100..100."""
|
|
39
|
+
arr = _rgba(image).astype(np.float32)
|
|
40
|
+
factor = (100.0 + float(contrast)) / 100.0
|
|
41
|
+
shift = float(brightness) / 100.0 * 128.0
|
|
42
|
+
rgb = (arr[..., :3] - 128.0) * factor + 128.0 + shift
|
|
43
|
+
arr[..., :3] = np.clip(rgb, 0, 255)
|
|
44
|
+
return _from_rgb(arr)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def levels(
|
|
48
|
+
image: Image.Image,
|
|
49
|
+
in_black: float,
|
|
50
|
+
in_white: float,
|
|
51
|
+
gamma: float,
|
|
52
|
+
out_black: float = 0.0,
|
|
53
|
+
out_white: float = 255.0,
|
|
54
|
+
) -> Image.Image:
|
|
55
|
+
"""A compact levels adjustment: input window, gamma, output window."""
|
|
56
|
+
arr = _rgba(image).astype(np.float32)
|
|
57
|
+
span = max(1.0, float(in_white) - float(in_black))
|
|
58
|
+
rgb = np.clip((arr[..., :3] - float(in_black)) / span, 0.0, 1.0)
|
|
59
|
+
rgb = np.power(rgb, 1.0 / max(0.05, float(gamma)))
|
|
60
|
+
rgb = rgb * (float(out_white) - float(out_black)) + float(out_black)
|
|
61
|
+
arr[..., :3] = np.clip(rgb, 0, 255)
|
|
62
|
+
return _from_rgb(arr)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _rgb_to_hsv(rgb: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
|
|
66
|
+
r, g, b = rgb[..., 0], rgb[..., 1], rgb[..., 2]
|
|
67
|
+
maxc = np.max(rgb, axis=-1)
|
|
68
|
+
minc = np.min(rgb, axis=-1)
|
|
69
|
+
delta = maxc - minc
|
|
70
|
+
safe = np.where(delta == 0, 1.0, delta)
|
|
71
|
+
rc = (maxc - r) / safe
|
|
72
|
+
gc = (maxc - g) / safe
|
|
73
|
+
bc = (maxc - b) / safe
|
|
74
|
+
hue = np.zeros_like(maxc)
|
|
75
|
+
hue = np.where((maxc == r) & (delta > 0), (bc - gc) % 6.0, hue)
|
|
76
|
+
hue = np.where((maxc == g) & (delta > 0), 2.0 + rc - bc, hue)
|
|
77
|
+
hue = np.where((maxc == b) & (delta > 0), 4.0 + gc - rc, hue)
|
|
78
|
+
hue = (hue / 6.0) % 1.0
|
|
79
|
+
sat = np.where(maxc > 1e-8, delta / np.maximum(maxc, 1e-8), 0.0)
|
|
80
|
+
return hue, sat, maxc
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _hsv_to_rgb(h: np.ndarray, s: np.ndarray, v: np.ndarray) -> np.ndarray:
|
|
84
|
+
i = np.floor(h * 6.0).astype(np.int32)
|
|
85
|
+
f = h * 6.0 - i
|
|
86
|
+
p = v * (1.0 - s)
|
|
87
|
+
q = v * (1.0 - f * s)
|
|
88
|
+
t = v * (1.0 - (1.0 - f) * s)
|
|
89
|
+
i_mod = i % 6
|
|
90
|
+
r = np.choose(i_mod, [v, q, p, p, t, v])
|
|
91
|
+
g = np.choose(i_mod, [t, v, v, q, p, p])
|
|
92
|
+
b = np.choose(i_mod, [p, p, t, v, v, q])
|
|
93
|
+
return np.stack([r, g, b], axis=-1)
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def hue_saturation(
|
|
97
|
+
image: Image.Image,
|
|
98
|
+
hue_degrees: float = 0.0,
|
|
99
|
+
saturation: float = 0.0,
|
|
100
|
+
lightness: float = 0.0,
|
|
101
|
+
) -> Image.Image:
|
|
102
|
+
"""Shift hue (degrees), saturation (-100..100), and value (-100..100)."""
|
|
103
|
+
arr = _rgba(image).astype(np.float32)
|
|
104
|
+
rgb = arr[..., :3] / 255.0
|
|
105
|
+
hue, sat, val = _rgb_to_hsv(rgb)
|
|
106
|
+
hue = (hue + float(hue_degrees) / 360.0) % 1.0
|
|
107
|
+
sat = np.clip(sat * (1.0 + float(saturation) / 100.0), 0.0, 1.0)
|
|
108
|
+
val = np.clip(val + float(lightness) / 100.0, 0.0, 1.0)
|
|
109
|
+
arr[..., :3] = _hsv_to_rgb(hue, sat, val) * 255.0
|
|
110
|
+
return _from_rgb(arr)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def highlights_shadows(image: Image.Image, highlight: float, shade: float) -> Image.Image:
|
|
114
|
+
"""Push the bright end and the dark end. Each argument is -100..100.
|
|
115
|
+
|
|
116
|
+
Positive highlight lightens pixels that are already bright and barely
|
|
117
|
+
touches dark ones. Positive shade lightens dark pixels and barely touches
|
|
118
|
+
bright ones. A negative value darkens that end. Midtones barely move.
|
|
119
|
+
Alpha is left as it was.
|
|
120
|
+
"""
|
|
121
|
+
arr = _rgba(image).astype(np.float32)
|
|
122
|
+
rgb = arr[..., :3]
|
|
123
|
+
luma = (rgb[..., 0] * 0.2126 + rgb[..., 1] * 0.7152 + rgb[..., 2] * 0.0722) / 255.0
|
|
124
|
+
# Squared weights fall off before the middle, so a highlight slider does
|
|
125
|
+
# not also lift the shadows.
|
|
126
|
+
highlight_weight = luma ** 2
|
|
127
|
+
shade_weight = (1.0 - luma) ** 2
|
|
128
|
+
rgb = rgb + (float(highlight) / 100.0) * 128.0 * highlight_weight[..., None]
|
|
129
|
+
rgb = rgb + (float(shade) / 100.0) * 128.0 * shade_weight[..., None]
|
|
130
|
+
arr[..., :3] = np.clip(rgb, 0, 255)
|
|
131
|
+
return _from_rgb(arr)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def color_grade(
|
|
135
|
+
image: Image.Image,
|
|
136
|
+
hue: float = 0.0,
|
|
137
|
+
saturation: float = 0.0,
|
|
138
|
+
brightness: float = 0.0,
|
|
139
|
+
contrast: float = 0.0,
|
|
140
|
+
highlight: float = 0.0,
|
|
141
|
+
shade: float = 0.0,
|
|
142
|
+
) -> Image.Image:
|
|
143
|
+
"""One combined grade: hue and saturation, then brightness and contrast, then highlight and shade."""
|
|
144
|
+
if all(abs(float(value)) < 1e-9 for value in (hue, saturation, brightness, contrast, highlight, shade)):
|
|
145
|
+
return image.copy()
|
|
146
|
+
graded = hue_saturation(image, hue_degrees=hue, saturation=saturation, lightness=0.0)
|
|
147
|
+
graded = brightness_contrast(graded, brightness, contrast)
|
|
148
|
+
return highlights_shadows(graded, highlight, shade)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def desaturate(image: Image.Image) -> Image.Image:
|
|
152
|
+
arr = _rgba(image).astype(np.float32)
|
|
153
|
+
luma = arr[..., 0] * 0.2126 + arr[..., 1] * 0.7152 + arr[..., 2] * 0.0722
|
|
154
|
+
arr[..., 0] = luma
|
|
155
|
+
arr[..., 1] = luma
|
|
156
|
+
arr[..., 2] = luma
|
|
157
|
+
return _from_rgb(arr)
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def invert(image: Image.Image) -> Image.Image:
|
|
161
|
+
arr = _rgba(image)
|
|
162
|
+
arr[..., :3] = 255 - arr[..., :3]
|
|
163
|
+
return Image.fromarray(arr, "RGBA")
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def blur(image: Image.Image, radius: float) -> Image.Image:
|
|
167
|
+
return gaussian_image(image.convert("RGBA"), radius)
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def sharpen(image: Image.Image) -> Image.Image:
|
|
171
|
+
return image.convert("RGBA").filter(ImageFilter.UnsharpMask(radius=2, percent=150, threshold=0))
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def pixelate(image: Image.Image, block: int) -> Image.Image:
|
|
175
|
+
return pixelate_image(image.convert("RGBA"), block)
|
pixedit/app.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Qt application entry used by the ``pix`` console script."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from PyQt6.QtGui import QColor, QIcon, QPixmap
|
|
9
|
+
from PyQt6.QtWidgets import QApplication
|
|
10
|
+
|
|
11
|
+
from pixedit import __version__
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def _stylesheet() -> str:
|
|
15
|
+
path = Path(__file__).resolve().parent / "resources" / "style.qss"
|
|
16
|
+
if path.is_file():
|
|
17
|
+
return path.read_text(encoding="utf-8")
|
|
18
|
+
return ""
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _icon() -> QIcon:
|
|
22
|
+
pixmap = QPixmap(32, 32)
|
|
23
|
+
pixmap.fill(QColor("#143544"))
|
|
24
|
+
return QIcon(pixmap)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def create_app() -> QApplication:
|
|
28
|
+
app = QApplication.instance() or QApplication(sys.argv)
|
|
29
|
+
app.setApplicationName("PixEdit")
|
|
30
|
+
app.setApplicationVersion(__version__)
|
|
31
|
+
app.setStyle("Fusion")
|
|
32
|
+
style = _stylesheet()
|
|
33
|
+
if style:
|
|
34
|
+
app.setStyleSheet(style)
|
|
35
|
+
app.setWindowIcon(_icon())
|
|
36
|
+
return app
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def run(path: str | None = None) -> int:
|
|
40
|
+
"""Show the editor and enter the Qt event loop."""
|
|
41
|
+
from pixedit.ui.main_window import MainWindow
|
|
42
|
+
|
|
43
|
+
app = create_app()
|
|
44
|
+
window = MainWindow()
|
|
45
|
+
window.show()
|
|
46
|
+
if path:
|
|
47
|
+
window.open_path(path)
|
|
48
|
+
return int(app.exec())
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def check() -> int:
|
|
52
|
+
"""Construct the window offscreen and return. Used by ``pix --check``."""
|
|
53
|
+
from pixedit.ui.main_window import MainWindow
|
|
54
|
+
|
|
55
|
+
app = create_app()
|
|
56
|
+
window = MainWindow()
|
|
57
|
+
window.show()
|
|
58
|
+
app.processEvents()
|
|
59
|
+
if window.session.document.width < 1 or not window.tool_by_id:
|
|
60
|
+
return 1
|
|
61
|
+
window.close()
|
|
62
|
+
app.processEvents()
|
|
63
|
+
return 0
|
pixedit/cli.py
ADDED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
"""Console entry point.
|
|
2
|
+
|
|
3
|
+
``pix`` launches the GUI, the same shape as ``rr`` launching ``rr_app``:
|
|
4
|
+
a short ``[project.scripts]`` command whose ``main`` imports the Qt app only
|
|
5
|
+
after argument parsing, and reports startup failures instead of dying silently.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import traceback
|
|
14
|
+
from datetime import datetime
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from pixedit import __version__
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _runtime_log_dir() -> Path:
|
|
21
|
+
if sys.platform == "darwin":
|
|
22
|
+
return Path.home() / "Library" / "Logs" / "pixedit"
|
|
23
|
+
return Path.home() / ".pixedit" / "logs"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _write_startup_log(title: str, message: str, exc: BaseException | None = None) -> Path | None:
|
|
27
|
+
try:
|
|
28
|
+
log_dir = _runtime_log_dir()
|
|
29
|
+
log_dir.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
stamp = datetime.now().strftime("%Y%m%d-%H%M%S")
|
|
31
|
+
path = log_dir / f"startup-{stamp}.log"
|
|
32
|
+
lines = [f"{title}\n", f"{message}\n"]
|
|
33
|
+
if exc is not None:
|
|
34
|
+
lines.append("\nTraceback:\n")
|
|
35
|
+
lines.append("".join(traceback.format_exception(exc)))
|
|
36
|
+
path.write_text("".join(lines), encoding="utf-8")
|
|
37
|
+
return path
|
|
38
|
+
except Exception:
|
|
39
|
+
return None
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _missing_dep_hint(mod: str) -> str:
|
|
43
|
+
return (
|
|
44
|
+
f"Missing dependency: {mod}\n\n"
|
|
45
|
+
"From a local checkout:\n"
|
|
46
|
+
" python -m venv .venv\n"
|
|
47
|
+
" source .venv/bin/activate\n"
|
|
48
|
+
" pip install -e .\n"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _display_available() -> bool:
|
|
53
|
+
if os.environ.get("QT_QPA_PLATFORM"):
|
|
54
|
+
return True
|
|
55
|
+
if sys.platform in {"win32", "darwin"}:
|
|
56
|
+
return True
|
|
57
|
+
return bool(os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY"))
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _parse_args(argv: list[str] | None) -> argparse.Namespace:
|
|
61
|
+
parser = argparse.ArgumentParser(
|
|
62
|
+
prog="pix",
|
|
63
|
+
description="Launch the PixEdit layered image editor.",
|
|
64
|
+
)
|
|
65
|
+
parser.add_argument("path", nargs="?", help="Image, PDF, or .pix project to open")
|
|
66
|
+
parser.add_argument("--version", action="version", version=f"pixedit {__version__}")
|
|
67
|
+
parser.add_argument(
|
|
68
|
+
"--check",
|
|
69
|
+
action="store_true",
|
|
70
|
+
help="Construct the window and exit. Uses the offscreen Qt platform when needed.",
|
|
71
|
+
)
|
|
72
|
+
return parser.parse_args(argv)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def main(argv: list[str] | None = None) -> int:
|
|
76
|
+
# Do not write bytecode into the checkout when the console script starts.
|
|
77
|
+
sys.dont_write_bytecode = True
|
|
78
|
+
args = _parse_args(argv)
|
|
79
|
+
|
|
80
|
+
if args.check:
|
|
81
|
+
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
|
82
|
+
try:
|
|
83
|
+
from pixedit.app import check
|
|
84
|
+
except ModuleNotFoundError as exc:
|
|
85
|
+
print(_missing_dep_hint(exc.name or "unknown"), file=sys.stderr)
|
|
86
|
+
return 1
|
|
87
|
+
try:
|
|
88
|
+
return int(check())
|
|
89
|
+
except Exception as exc:
|
|
90
|
+
log_path = _write_startup_log("PixEdit check failed", "GUI failed to construct.", exc)
|
|
91
|
+
print(f"PixEdit check failed: {exc}", file=sys.stderr)
|
|
92
|
+
if log_path:
|
|
93
|
+
print(f"Details: {log_path}", file=sys.stderr)
|
|
94
|
+
return 1
|
|
95
|
+
|
|
96
|
+
if not _display_available():
|
|
97
|
+
print(
|
|
98
|
+
"PixEdit needs a display to open the editor.\n"
|
|
99
|
+
"On a headless machine, run `pix --check` to construct the UI offscreen,\n"
|
|
100
|
+
"or `pytest` to exercise the document, layer, and redaction code.",
|
|
101
|
+
file=sys.stderr,
|
|
102
|
+
)
|
|
103
|
+
return 1
|
|
104
|
+
|
|
105
|
+
try:
|
|
106
|
+
from pixedit.app import run
|
|
107
|
+
except ModuleNotFoundError as exc:
|
|
108
|
+
message = _missing_dep_hint(exc.name or "unknown")
|
|
109
|
+
log_path = _write_startup_log("PixEdit failed to start", message, exc)
|
|
110
|
+
print(message, file=sys.stderr)
|
|
111
|
+
if log_path:
|
|
112
|
+
print(f"Details: {log_path}", file=sys.stderr)
|
|
113
|
+
return 1
|
|
114
|
+
except Exception as exc:
|
|
115
|
+
log_path = _write_startup_log("PixEdit failed to initialize", "Unexpected startup error.", exc)
|
|
116
|
+
print(f"PixEdit failed to initialize: {exc}", file=sys.stderr)
|
|
117
|
+
if log_path:
|
|
118
|
+
print(f"Details: {log_path}", file=sys.stderr)
|
|
119
|
+
return 1
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
return int(run(args.path) or 0)
|
|
123
|
+
except KeyboardInterrupt:
|
|
124
|
+
return 0
|
|
125
|
+
except Exception as exc:
|
|
126
|
+
log_path = _write_startup_log("PixEdit crashed on launch", "Unexpected error while starting the GUI.", exc)
|
|
127
|
+
print(f"PixEdit crashed on launch: {exc}", file=sys.stderr)
|
|
128
|
+
if log_path:
|
|
129
|
+
print(f"Details: {log_path}", file=sys.stderr)
|
|
130
|
+
traceback.print_exc()
|
|
131
|
+
return 1
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
if __name__ == "__main__":
|
|
135
|
+
raise SystemExit(main())
|
pixedit/composite.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Flatten a document into one RGBA image.
|
|
2
|
+
|
|
3
|
+
Pipeline for each visible layer, bottom to top:
|
|
4
|
+
|
|
5
|
+
1. Redaction layers do not draw their own pixels. They take the canvas so
|
|
6
|
+
far, run the mask effect (pixelate, blur, solid, noise, and the rest),
|
|
7
|
+
and mix that back through the mask. Source layers underneath stay untouched.
|
|
8
|
+
2. Raster and text layers are blended with a CSS-style "simple alpha
|
|
9
|
+
compositing" formula (source-over, with the blend mode applied only where
|
|
10
|
+
both coverages overlap). That keeps Multiply/Screen/Overlay from turning
|
|
11
|
+
into black when the backdrop is transparent.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
from PIL import Image
|
|
18
|
+
|
|
19
|
+
from pixedit.model import BLEND_MODES, Document, Layer
|
|
20
|
+
from pixedit.pixels import mask_effect
|
|
21
|
+
|
|
22
|
+
# geom.place_image is imported lazily inside mask placement to keep import
|
|
23
|
+
# order boring: composite -> model, and geom -> model, neither imports the other
|
|
24
|
+
# at module level for placement helpers defined here.
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _blend_color(mode: str, dst: np.ndarray, src: np.ndarray) -> np.ndarray:
|
|
28
|
+
if mode == "multiply":
|
|
29
|
+
return src * dst
|
|
30
|
+
if mode == "screen":
|
|
31
|
+
return 1.0 - (1.0 - src) * (1.0 - dst)
|
|
32
|
+
if mode == "overlay":
|
|
33
|
+
# Overlay is multiply on the dark half of the backdrop and screen on the light half.
|
|
34
|
+
return np.where(
|
|
35
|
+
dst < 0.5,
|
|
36
|
+
2.0 * src * dst,
|
|
37
|
+
1.0 - 2.0 * (1.0 - src) * (1.0 - dst),
|
|
38
|
+
)
|
|
39
|
+
return src
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def blend_arrays(
|
|
43
|
+
dst: np.ndarray,
|
|
44
|
+
src: np.ndarray,
|
|
45
|
+
opacity: float = 1.0,
|
|
46
|
+
mode: str = "normal",
|
|
47
|
+
) -> np.ndarray:
|
|
48
|
+
"""Composite ``src`` over ``dst``. Both are float HxWx4 straight alpha, 0..1."""
|
|
49
|
+
if mode not in BLEND_MODES:
|
|
50
|
+
mode = "normal"
|
|
51
|
+
src_a = np.clip(src[..., 3:4] * float(opacity), 0.0, 1.0)
|
|
52
|
+
dst_a = np.clip(dst[..., 3:4], 0.0, 1.0)
|
|
53
|
+
src_rgb = src[..., :3]
|
|
54
|
+
dst_rgb = dst[..., :3]
|
|
55
|
+
blended = _blend_color(mode, dst_rgb, src_rgb)
|
|
56
|
+
out_a = src_a + dst_a * (1.0 - src_a)
|
|
57
|
+
# Where the backdrop is clear, fall back to the source color so blend
|
|
58
|
+
# modes do not mix against an imaginary black canvas.
|
|
59
|
+
numer = src_a * (src_rgb * (1.0 - dst_a) + blended * dst_a) + dst_rgb * dst_a * (1.0 - src_a)
|
|
60
|
+
out_rgb = np.where(out_a > 1e-6, numer / np.maximum(out_a, 1e-6), 0.0)
|
|
61
|
+
return np.concatenate(
|
|
62
|
+
[np.clip(out_rgb, 0.0, 1.0), np.clip(out_a, 0.0, 1.0)],
|
|
63
|
+
axis=-1,
|
|
64
|
+
).astype(np.float32)
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def pil_to_float(image: Image.Image) -> np.ndarray:
|
|
68
|
+
rgba = image.convert("RGBA")
|
|
69
|
+
return np.asarray(rgba).astype(np.float32) / 255.0
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def float_to_pil(array: np.ndarray) -> Image.Image:
|
|
73
|
+
uint8 = np.clip(np.rint(array * 255.0), 0, 255).astype(np.uint8)
|
|
74
|
+
return Image.fromarray(np.ascontiguousarray(uint8), "RGBA")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def _intersection(
|
|
78
|
+
ax: int, ay: int, aw: int, ah: int, bw: int, bh: int
|
|
79
|
+
) -> tuple[int, int, int, int, int, int] | None:
|
|
80
|
+
"""Overlap of a placed image (ax, ay, aw, ah) with a canvas of size bw×bh.
|
|
81
|
+
|
|
82
|
+
Returns canvas x, y, width, height and the source origin inside the image.
|
|
83
|
+
"""
|
|
84
|
+
x0 = max(0, ax)
|
|
85
|
+
y0 = max(0, ay)
|
|
86
|
+
x1 = min(bw, ax + aw)
|
|
87
|
+
y1 = min(bh, ay + ah)
|
|
88
|
+
if x1 <= x0 or y1 <= y0:
|
|
89
|
+
return None
|
|
90
|
+
return x0, y0, x1 - x0, y1 - y0, x0 - ax, y0 - ay
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def blit_layer(canvas: np.ndarray, layer: Layer) -> np.ndarray:
|
|
94
|
+
image = layer.image
|
|
95
|
+
overlap = _intersection(layer.x, layer.y, image.width, image.height, canvas.shape[1], canvas.shape[0])
|
|
96
|
+
if overlap is None or layer.opacity <= 0:
|
|
97
|
+
return canvas
|
|
98
|
+
x0, y0, width, height, sx, sy = overlap
|
|
99
|
+
cropped = image.crop((sx, sy, sx + width, sy + height))
|
|
100
|
+
src = pil_to_float(cropped)
|
|
101
|
+
dst = canvas[y0 : y0 + height, x0 : x0 + width]
|
|
102
|
+
canvas[y0 : y0 + height, x0 : x0 + width] = blend_arrays(dst, src, layer.opacity, layer.blend_mode)
|
|
103
|
+
return canvas
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def mask_to_canvas(layer: Layer, width: int, height: int) -> np.ndarray:
|
|
107
|
+
"""Place a redaction mask into a float HxW array covering the document."""
|
|
108
|
+
full = np.zeros((height, width), dtype=np.float32)
|
|
109
|
+
if layer.mask is None:
|
|
110
|
+
return full
|
|
111
|
+
overlap = _intersection(layer.x, layer.y, layer.mask.width, layer.mask.height, width, height)
|
|
112
|
+
if overlap is None:
|
|
113
|
+
return full
|
|
114
|
+
x0, y0, mw, mh, sx, sy = overlap
|
|
115
|
+
cropped = layer.mask.crop((sx, sy, sx + mw, sy + mh))
|
|
116
|
+
full[y0 : y0 + mh, x0 : x0 + mw] = np.asarray(cropped).astype(np.float32) / 255.0
|
|
117
|
+
return full
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def apply_redaction(canvas: np.ndarray, layer: Layer) -> np.ndarray:
|
|
121
|
+
"""Mix the mask effect through ``layer``'s mask.
|
|
122
|
+
|
|
123
|
+
The effect is computed from the stack *below* this layer, so pixels in
|
|
124
|
+
earlier layers are not rewritten. Later layers still composite normally
|
|
125
|
+
and can sit above the mask (annotations, for example).
|
|
126
|
+
"""
|
|
127
|
+
height, width = canvas.shape[:2]
|
|
128
|
+
mask = mask_to_canvas(layer, width, height) * float(layer.opacity)
|
|
129
|
+
if float(mask.max()) <= 0.0:
|
|
130
|
+
return canvas
|
|
131
|
+
info = layer.redaction
|
|
132
|
+
kind = info.kind if info is not None else "pixelate"
|
|
133
|
+
amount = info.amount if info is not None else 12.0
|
|
134
|
+
color = info.color if info is not None else (0, 0, 0, 255)
|
|
135
|
+
seed = info.seed if info is not None else 1
|
|
136
|
+
effect = mask_effect(canvas, kind, amount, color, seed)
|
|
137
|
+
mix = mask[..., None]
|
|
138
|
+
return (effect * mix + canvas * (1.0 - mix)).astype(np.float32)
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def composite(document: Document) -> Image.Image:
|
|
142
|
+
"""Flatten ``document`` to one RGBA image the size of the canvas."""
|
|
143
|
+
canvas = np.zeros((document.height, document.width, 4), dtype=np.float32)
|
|
144
|
+
for layer in document.layers:
|
|
145
|
+
if not layer.visible or layer.opacity <= 0:
|
|
146
|
+
continue
|
|
147
|
+
if layer.kind == "redaction":
|
|
148
|
+
canvas = apply_redaction(canvas, layer)
|
|
149
|
+
else:
|
|
150
|
+
canvas = blit_layer(canvas, layer)
|
|
151
|
+
return float_to_pil(canvas)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def composite_over(document: Document, background: tuple[int, int, int, int]) -> Image.Image:
|
|
155
|
+
"""Flatten onto a solid background. Used by JPEG export."""
|
|
156
|
+
flat = composite(document)
|
|
157
|
+
base = Image.new("RGBA", flat.size, background)
|
|
158
|
+
return Image.alpha_composite(base, flat)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def merge_pair(lower: Layer, upper: Layer) -> Layer:
|
|
162
|
+
"""Bake ``upper`` into ``lower`` and return a new Normal raster layer.
|
|
163
|
+
|
|
164
|
+
The temporary canvas covers the union of both bitmaps, including pixels
|
|
165
|
+
that sit outside the document, so a merge does not clip overflow.
|
|
166
|
+
Redaction is baked by running the normal compositor on just these two
|
|
167
|
+
layers: the mask still samples only ``lower``, which is the whole stack
|
|
168
|
+
below the redaction *within this pair*.
|
|
169
|
+
"""
|
|
170
|
+
from pixedit.model import Layer as LayerCls
|
|
171
|
+
from pixedit.model import new_id
|
|
172
|
+
|
|
173
|
+
def extent(layer: Layer) -> tuple[int, int, int, int]:
|
|
174
|
+
image = layer.bitmap()
|
|
175
|
+
return layer.x, layer.y, layer.x + image.width, layer.y + image.height
|
|
176
|
+
|
|
177
|
+
boxes = [extent(lower), extent(upper)]
|
|
178
|
+
min_x = min(box[0] for box in boxes)
|
|
179
|
+
min_y = min(box[1] for box in boxes)
|
|
180
|
+
max_x = max(box[2] for box in boxes)
|
|
181
|
+
max_y = max(box[3] for box in boxes)
|
|
182
|
+
width = max(1, max_x - min_x)
|
|
183
|
+
height = max(1, max_y - min_y)
|
|
184
|
+
a = lower.clone()
|
|
185
|
+
b = upper.clone()
|
|
186
|
+
a.x -= min_x
|
|
187
|
+
a.y -= min_y
|
|
188
|
+
b.x -= min_x
|
|
189
|
+
b.y -= min_y
|
|
190
|
+
temp = Document(width=width, height=height, layers=[a, b])
|
|
191
|
+
flat = composite(temp)
|
|
192
|
+
alpha = np.asarray(flat)[..., 3]
|
|
193
|
+
ys, xs = np.nonzero(alpha > 0)
|
|
194
|
+
if len(xs) == 0:
|
|
195
|
+
image = Image.new("RGBA", (1, 1), (0, 0, 0, 0))
|
|
196
|
+
x = y = 0
|
|
197
|
+
else:
|
|
198
|
+
left, top, right, bottom = int(xs.min()), int(ys.min()), int(xs.max()) + 1, int(ys.max()) + 1
|
|
199
|
+
image = flat.crop((left, top, right, bottom))
|
|
200
|
+
x = min_x + left
|
|
201
|
+
y = min_y + top
|
|
202
|
+
return LayerCls(
|
|
203
|
+
id=new_id(),
|
|
204
|
+
name=lower.name,
|
|
205
|
+
kind="raster",
|
|
206
|
+
visible=lower.visible,
|
|
207
|
+
opacity=1.0,
|
|
208
|
+
blend_mode="normal",
|
|
209
|
+
image=image,
|
|
210
|
+
x=x,
|
|
211
|
+
y=y,
|
|
212
|
+
)
|
pixedit/factory.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
"""Constructors for the layer kinds the editor creates."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import random
|
|
6
|
+
|
|
7
|
+
from PIL import Image
|
|
8
|
+
|
|
9
|
+
from pixedit.model import Layer, RedactionInfo, TextInfo, new_id
|
|
10
|
+
from pixedit.textutil import render_text
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def raster_layer(name: str, image: Image.Image, x: int = 0, y: int = 0) -> Layer:
|
|
14
|
+
return Layer(
|
|
15
|
+
id=new_id(),
|
|
16
|
+
name=name,
|
|
17
|
+
kind="raster",
|
|
18
|
+
image=image.convert("RGBA"),
|
|
19
|
+
x=int(x),
|
|
20
|
+
y=int(y),
|
|
21
|
+
)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def blank_layer(name: str, width: int, height: int, fill: tuple[int, int, int, int] = (0, 0, 0, 0)) -> Layer:
|
|
25
|
+
"""A document-sized plate (flatten, or an explicit full-canvas fill)."""
|
|
26
|
+
return raster_layer(name, Image.new("RGBA", (width, height), fill))
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def empty_raster_layer(name: str) -> Layer:
|
|
30
|
+
"""A content-sized blank layer. Painting grows it around the new marks."""
|
|
31
|
+
return raster_layer(name, Image.new("RGBA", (1, 1), (0, 0, 0, 0)), 0, 0)
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def text_layer(name: str, info: TextInfo, x: int, y: int) -> Layer:
|
|
35
|
+
return Layer(
|
|
36
|
+
id=new_id(),
|
|
37
|
+
name=name,
|
|
38
|
+
kind="text",
|
|
39
|
+
image=render_text(info),
|
|
40
|
+
x=int(x),
|
|
41
|
+
y=int(y),
|
|
42
|
+
text=info,
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def redaction_layer(
|
|
47
|
+
name: str,
|
|
48
|
+
mask: Image.Image,
|
|
49
|
+
kind: str,
|
|
50
|
+
amount: float,
|
|
51
|
+
x: int = 0,
|
|
52
|
+
y: int = 0,
|
|
53
|
+
color: tuple[int, int, int, int] = (0, 0, 0, 255),
|
|
54
|
+
seed: int | None = None,
|
|
55
|
+
) -> Layer:
|
|
56
|
+
if seed is None:
|
|
57
|
+
seed = random.randrange(1, 2**31)
|
|
58
|
+
return Layer(
|
|
59
|
+
id=new_id(),
|
|
60
|
+
name=name,
|
|
61
|
+
kind="redaction",
|
|
62
|
+
image=Image.new("RGBA", (1, 1), (0, 0, 0, 0)),
|
|
63
|
+
x=int(x),
|
|
64
|
+
y=int(y),
|
|
65
|
+
mask=mask.convert("L"),
|
|
66
|
+
redaction=RedactionInfo(
|
|
67
|
+
kind=kind,
|
|
68
|
+
amount=float(amount),
|
|
69
|
+
color=tuple(int(channel) for channel in color),
|
|
70
|
+
seed=int(seed),
|
|
71
|
+
),
|
|
72
|
+
)
|