Pixseal 0.2.1.post1__cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.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.
Potentially problematic release.
This version of Pixseal might be problematic. Click here for more details.
- Pixseal/__init__.py +26 -0
- Pixseal/imageSigner.py +191 -0
- Pixseal/imageValidator.py +206 -0
- Pixseal/simpleImage.py +35 -0
- Pixseal/simpleImage_ext.cpython-314-x86_64-linux-gnu.so +0 -0
- Pixseal/simpleImage_py.py +319 -0
- pixseal-0.2.1.post1.dist-info/METADATA +7 -0
- pixseal-0.2.1.post1.dist-info/RECORD +10 -0
- pixseal-0.2.1.post1.dist-info/WHEEL +7 -0
- pixseal-0.2.1.post1.dist-info/top_level.txt +1 -0
Pixseal/__init__.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
from .simpleImage import ImageInput, SimpleImage
|
|
2
|
+
from .imageSigner import (
|
|
3
|
+
BinaryProvider,
|
|
4
|
+
addHiddenBit,
|
|
5
|
+
signImage,
|
|
6
|
+
)
|
|
7
|
+
from .imageValidator import (
|
|
8
|
+
binaryToString,
|
|
9
|
+
buildValidationReport,
|
|
10
|
+
deduplicate,
|
|
11
|
+
readHiddenBit,
|
|
12
|
+
validateImage,
|
|
13
|
+
)
|
|
14
|
+
|
|
15
|
+
__all__ = [
|
|
16
|
+
"SimpleImage",
|
|
17
|
+
"ImageInput",
|
|
18
|
+
"BinaryProvider",
|
|
19
|
+
"addHiddenBit",
|
|
20
|
+
"signImage",
|
|
21
|
+
"binaryToString",
|
|
22
|
+
"buildValidationReport",
|
|
23
|
+
"deduplicate",
|
|
24
|
+
"readHiddenBit",
|
|
25
|
+
"validateImage",
|
|
26
|
+
]
|
Pixseal/imageSigner.py
ADDED
|
@@ -0,0 +1,191 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
from cryptography.hazmat.primitives import hashes, serialization
|
|
4
|
+
from cryptography.hazmat.primitives.asymmetric import padding
|
|
5
|
+
|
|
6
|
+
try:
|
|
7
|
+
from line_profiler import profile # type: ignore
|
|
8
|
+
except ImportError: # pragma: no cover
|
|
9
|
+
def profile(func):
|
|
10
|
+
return func
|
|
11
|
+
|
|
12
|
+
from .simpleImage import ImageInput, SimpleImage
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class BinaryProvider:
|
|
16
|
+
|
|
17
|
+
# Constructor
|
|
18
|
+
def __init__(
|
|
19
|
+
self,
|
|
20
|
+
hiddenString,
|
|
21
|
+
startString="START-VALIDATION\n",
|
|
22
|
+
endString="\nEND-VALIDATION",
|
|
23
|
+
):
|
|
24
|
+
self.hiddenBits = self._stringToBits(hiddenString)
|
|
25
|
+
self.startBits = self._stringToBits(startString)
|
|
26
|
+
self.endBits = self._stringToBits(endString)
|
|
27
|
+
|
|
28
|
+
# Convert string to contiguous binary digits
|
|
29
|
+
def _stringToBits(self, string):
|
|
30
|
+
bits = []
|
|
31
|
+
for char in string:
|
|
32
|
+
binary = format(ord(char), "08b")
|
|
33
|
+
bits.extend(int(bit) for bit in binary)
|
|
34
|
+
return bits
|
|
35
|
+
|
|
36
|
+
def _expandPayload(self, count: int):
|
|
37
|
+
if count <= 0:
|
|
38
|
+
return []
|
|
39
|
+
payloadLen = len(self.hiddenBits)
|
|
40
|
+
if payloadLen == 0:
|
|
41
|
+
raise ValueError("Hidden payload is empty")
|
|
42
|
+
repeats, remainder = divmod(count, payloadLen)
|
|
43
|
+
return (self.hiddenBits * repeats) + self.hiddenBits[:remainder]
|
|
44
|
+
|
|
45
|
+
def buildBitArray(self, pixelCount: int):
|
|
46
|
+
startLen = len(self.startBits)
|
|
47
|
+
endLen = len(self.endBits)
|
|
48
|
+
if pixelCount < startLen + endLen:
|
|
49
|
+
raise ValueError("Image is too small to fit start/end sentinels")
|
|
50
|
+
|
|
51
|
+
bits = [0] * pixelCount
|
|
52
|
+
|
|
53
|
+
# 1. Place START-VALIDATION bits first
|
|
54
|
+
bits[:startLen] = self.startBits
|
|
55
|
+
|
|
56
|
+
# 2. Fill the remaining slots by repeating the payload bits
|
|
57
|
+
payloadSlots = pixelCount - startLen
|
|
58
|
+
payloadBits = self._expandPayload(payloadSlots)
|
|
59
|
+
bits[startLen:] = payloadBits[:payloadSlots]
|
|
60
|
+
|
|
61
|
+
# 3. Overwrite the tail with END-VALIDATION bits
|
|
62
|
+
tailStart = pixelCount - endLen
|
|
63
|
+
bits[tailStart:] = self.endBits
|
|
64
|
+
|
|
65
|
+
return bits
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
@profile
|
|
69
|
+
def addHiddenBit(imageInput: ImageInput, hiddenBinary: BinaryProvider):
|
|
70
|
+
img = SimpleImage.open(imageInput)
|
|
71
|
+
width, height = img.size
|
|
72
|
+
pixels = img._pixels # direct buffer access for performance
|
|
73
|
+
total = width * height
|
|
74
|
+
payloadBits = hiddenBinary.buildBitArray(total)
|
|
75
|
+
|
|
76
|
+
# Iterate over every pixel and inject one bit
|
|
77
|
+
for idx in range(total):
|
|
78
|
+
base = idx * 3
|
|
79
|
+
# Read the pixel
|
|
80
|
+
r = pixels[base]
|
|
81
|
+
g = pixels[base + 1]
|
|
82
|
+
b = pixels[base + 2]
|
|
83
|
+
|
|
84
|
+
# Calculate the distance from 127
|
|
85
|
+
diffR = r - 127
|
|
86
|
+
if diffR < 0:
|
|
87
|
+
diffR = -diffR
|
|
88
|
+
diffG = g - 127
|
|
89
|
+
if diffG < 0:
|
|
90
|
+
diffG = -diffG
|
|
91
|
+
diffB = b - 127
|
|
92
|
+
if diffB < 0:
|
|
93
|
+
diffB = -diffB
|
|
94
|
+
|
|
95
|
+
# Pick the component farthest from 127
|
|
96
|
+
maxDiff = diffR
|
|
97
|
+
if diffG > maxDiff:
|
|
98
|
+
maxDiff = diffG
|
|
99
|
+
if diffB > maxDiff:
|
|
100
|
+
maxDiff = diffB
|
|
101
|
+
|
|
102
|
+
# Actual value of that channel
|
|
103
|
+
if maxDiff == diffR:
|
|
104
|
+
targetColorValue = r
|
|
105
|
+
elif maxDiff == diffG:
|
|
106
|
+
targetColorValue = g
|
|
107
|
+
else:
|
|
108
|
+
targetColorValue = b
|
|
109
|
+
|
|
110
|
+
# Channels >=127 are decremented, <127 incremented
|
|
111
|
+
addDirection = 1 if targetColorValue < 127 else -1
|
|
112
|
+
|
|
113
|
+
# Pull next bit from provider
|
|
114
|
+
bit = payloadBits[idx]
|
|
115
|
+
|
|
116
|
+
# Force the selected channel parity to match the bit
|
|
117
|
+
if maxDiff == diffR:
|
|
118
|
+
if r % 2 != bit:
|
|
119
|
+
r += addDirection
|
|
120
|
+
if maxDiff == diffG:
|
|
121
|
+
if g % 2 != bit:
|
|
122
|
+
g += addDirection
|
|
123
|
+
if maxDiff == diffB:
|
|
124
|
+
if b % 2 != bit:
|
|
125
|
+
b += addDirection
|
|
126
|
+
|
|
127
|
+
# Write the updated pixel
|
|
128
|
+
pixels[base] = r
|
|
129
|
+
pixels[base + 1] = g
|
|
130
|
+
pixels[base + 2] = b
|
|
131
|
+
|
|
132
|
+
# Return the modified image
|
|
133
|
+
return img
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def stringCryptor(plaintext: str, public_key) -> str:
|
|
137
|
+
|
|
138
|
+
ciphertext = public_key.encrypt(
|
|
139
|
+
plaintext.encode("utf-8"),
|
|
140
|
+
padding.OAEP(
|
|
141
|
+
mgf=padding.MGF1(algorithm=hashes.SHA256()),
|
|
142
|
+
algorithm=hashes.SHA256(),
|
|
143
|
+
label=None,
|
|
144
|
+
),
|
|
145
|
+
)
|
|
146
|
+
|
|
147
|
+
return base64.b64encode(ciphertext).decode("ascii")
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
# main
|
|
151
|
+
# Image input (path or bytes) + payload string => returns image with embedded payload
|
|
152
|
+
def signImage(imageInput: ImageInput, hiddenString, publicKeyPath=None):
|
|
153
|
+
"""
|
|
154
|
+
Embed a payload into an image using the parity-based steganography scheme.
|
|
155
|
+
|
|
156
|
+
Args:
|
|
157
|
+
imageInput: File path, bytes, or file-like object accepted by SimpleImage.
|
|
158
|
+
hiddenString: Text payload that should be written into the image.
|
|
159
|
+
publicKeyPath: Optional path to a PEM-encoded RSA public key used to
|
|
160
|
+
encrypt the payload and sentinel markers before embedding.
|
|
161
|
+
|
|
162
|
+
Returns:
|
|
163
|
+
SimpleImage instance whose pixels include the signed payload.
|
|
164
|
+
|
|
165
|
+
Raises:
|
|
166
|
+
FileNotFoundError: If a public key path is provided but the file is missing.
|
|
167
|
+
ValueError: If the file is not a valid PEM public key.
|
|
168
|
+
"""
|
|
169
|
+
|
|
170
|
+
if publicKeyPath: # When encryption key is supplied
|
|
171
|
+
key_path = Path(publicKeyPath)
|
|
172
|
+
if not key_path.is_file():
|
|
173
|
+
raise FileNotFoundError(f"Public key file not found: {publicKeyPath}")
|
|
174
|
+
|
|
175
|
+
pem_data = key_path.read_bytes()
|
|
176
|
+
if b"BEGIN PUBLIC KEY" not in pem_data:
|
|
177
|
+
raise ValueError("Provided file does not contain a valid public key")
|
|
178
|
+
|
|
179
|
+
public_key = serialization.load_pem_public_key(pem_data)
|
|
180
|
+
|
|
181
|
+
hiddenBinary = BinaryProvider(
|
|
182
|
+
hiddenString=stringCryptor(hiddenString, public_key) + "\n",
|
|
183
|
+
startString=stringCryptor("START-VALIDATION", public_key) + "\n",
|
|
184
|
+
endString="\n" + stringCryptor("END-VALIDATION", public_key),
|
|
185
|
+
)
|
|
186
|
+
|
|
187
|
+
else: # Plain-text payload
|
|
188
|
+
hiddenBinary = BinaryProvider(hiddenString + "\n")
|
|
189
|
+
|
|
190
|
+
signedImage = addHiddenBit(imageInput, hiddenBinary)
|
|
191
|
+
return signedImage
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
from cryptography.hazmat.primitives import serialization, hashes
|
|
3
|
+
from cryptography.hazmat.primitives.asymmetric import padding
|
|
4
|
+
|
|
5
|
+
try:
|
|
6
|
+
from line_profiler import profile # type: ignore
|
|
7
|
+
except ImportError: # pragma: no cover
|
|
8
|
+
def profile(func):
|
|
9
|
+
return func
|
|
10
|
+
|
|
11
|
+
from .simpleImage import ImageInput, SimpleImage
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def binaryToString(binaryCode):
|
|
15
|
+
string = []
|
|
16
|
+
for i in range(0, len(binaryCode), 8):
|
|
17
|
+
byte = binaryCode[i : i + 8]
|
|
18
|
+
decimal = int(byte, 2)
|
|
19
|
+
character = chr(decimal)
|
|
20
|
+
string.append(character)
|
|
21
|
+
return "".join(string)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@profile
|
|
25
|
+
def readHiddenBit(imageInput: ImageInput):
|
|
26
|
+
img = SimpleImage.open(imageInput)
|
|
27
|
+
width, height = img.size
|
|
28
|
+
pixels = img._pixels # direct buffer access for performance
|
|
29
|
+
total = width * height
|
|
30
|
+
bits = []
|
|
31
|
+
append_bit = bits.append
|
|
32
|
+
|
|
33
|
+
for idx in range(total):
|
|
34
|
+
base = idx * 3
|
|
35
|
+
r = pixels[base]
|
|
36
|
+
g = pixels[base + 1]
|
|
37
|
+
b = pixels[base + 2]
|
|
38
|
+
|
|
39
|
+
diffR = r - 127
|
|
40
|
+
if diffR < 0:
|
|
41
|
+
diffR = -diffR
|
|
42
|
+
diffG = g - 127
|
|
43
|
+
if diffG < 0:
|
|
44
|
+
diffG = -diffG
|
|
45
|
+
diffB = b - 127
|
|
46
|
+
if diffB < 0:
|
|
47
|
+
diffB = -diffB
|
|
48
|
+
|
|
49
|
+
maxDiff = diffR
|
|
50
|
+
if diffG > maxDiff:
|
|
51
|
+
maxDiff = diffG
|
|
52
|
+
if diffB > maxDiff:
|
|
53
|
+
maxDiff = diffB
|
|
54
|
+
|
|
55
|
+
append_bit("1" if maxDiff % 2 == 0 else "0")
|
|
56
|
+
|
|
57
|
+
return "".join(bits)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def deduplicate(arr):
|
|
61
|
+
deduplicated = []
|
|
62
|
+
freq = {}
|
|
63
|
+
most_common = None
|
|
64
|
+
most_count = 0
|
|
65
|
+
|
|
66
|
+
for i, value in enumerate(arr):
|
|
67
|
+
freq[value] = freq.get(value, 0) + 1
|
|
68
|
+
if freq[value] > most_count:
|
|
69
|
+
most_count = freq[value]
|
|
70
|
+
most_common = value
|
|
71
|
+
|
|
72
|
+
if i == 0 or value != arr[i - 1]:
|
|
73
|
+
deduplicated.append(value)
|
|
74
|
+
|
|
75
|
+
return deduplicated, most_common
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def tailCheck(arr: list[str]):
|
|
79
|
+
if len(arr) != 4:
|
|
80
|
+
return None # Not required
|
|
81
|
+
|
|
82
|
+
full_cipher = arr[1] # complete ciphertext
|
|
83
|
+
truncated_cipher = arr[2] # incomplete ciphertext
|
|
84
|
+
|
|
85
|
+
return full_cipher.startswith(truncated_cipher)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def buildValidationReport(decrypted, tailCheck: bool, skipPlain: bool = False):
|
|
89
|
+
# Length after deduplication/decryption
|
|
90
|
+
arrayLength = len(decrypted)
|
|
91
|
+
|
|
92
|
+
# 1. Check that the deduplicated sequence length is valid
|
|
93
|
+
lengthCheck = arrayLength in (3, 4)
|
|
94
|
+
|
|
95
|
+
# 2. Validate start/end markers
|
|
96
|
+
startCheck = decrypted[0] == "START-VALIDATION" if decrypted else False
|
|
97
|
+
endCheck = decrypted[-1] == "END-VALIDATION" if decrypted else False
|
|
98
|
+
|
|
99
|
+
# 4. Determine whether payload was successfully decrypted
|
|
100
|
+
decryptedPayload = decrypted[1] if len(decrypted) > 1 else ""
|
|
101
|
+
isDecrypted = bool(decryptedPayload) and not decryptedPayload.endswith("==")
|
|
102
|
+
|
|
103
|
+
checkList = [lengthCheck, startCheck, endCheck, isDecrypted]
|
|
104
|
+
# 5. Parse tailCheck result
|
|
105
|
+
if tailCheck is None:
|
|
106
|
+
tailCheckResult = "Not Required"
|
|
107
|
+
else:
|
|
108
|
+
tailCheckResult = tailCheck
|
|
109
|
+
checkList.append(tailCheckResult)
|
|
110
|
+
|
|
111
|
+
# Overall verdict requires every check to pass
|
|
112
|
+
verdict = all(checkList)
|
|
113
|
+
|
|
114
|
+
result = {
|
|
115
|
+
"arrayLength": arrayLength,
|
|
116
|
+
"lengthCheck": lengthCheck,
|
|
117
|
+
"startCheck": startCheck,
|
|
118
|
+
"endCheck": endCheck,
|
|
119
|
+
"isDecrypted": isDecrypted,
|
|
120
|
+
"tailCheckResult": tailCheckResult,
|
|
121
|
+
"verdict": verdict,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if skipPlain:
|
|
125
|
+
result["decryptSkipMessage"] = (
|
|
126
|
+
"Skip decrypt: payload was plain or corrupted text despite decrypt request."
|
|
127
|
+
)
|
|
128
|
+
|
|
129
|
+
return result
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def decrypt_array(deduplicated, privKeyPath):
|
|
133
|
+
# Load PEM private key
|
|
134
|
+
with open(privKeyPath, "rb") as key_file:
|
|
135
|
+
private_key = serialization.load_pem_private_key(
|
|
136
|
+
key_file.read(),
|
|
137
|
+
password=None,
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
decrypted = []
|
|
141
|
+
skippedPlainCount = 0
|
|
142
|
+
decryptError = False
|
|
143
|
+
for item in deduplicated:
|
|
144
|
+
if item.endswith("=="):
|
|
145
|
+
try:
|
|
146
|
+
cipher_bytes = base64.b64decode(item)
|
|
147
|
+
plain_bytes = private_key.decrypt(
|
|
148
|
+
cipher_bytes,
|
|
149
|
+
padding.OAEP(
|
|
150
|
+
mgf=padding.MGF1(algorithm=hashes.SHA256()),
|
|
151
|
+
algorithm=hashes.SHA256(),
|
|
152
|
+
label=None,
|
|
153
|
+
),
|
|
154
|
+
)
|
|
155
|
+
decrypted.append(plain_bytes.decode("utf-8"))
|
|
156
|
+
except Exception as exc:
|
|
157
|
+
print(exc)
|
|
158
|
+
decryptError = True
|
|
159
|
+
decrypted.append(item)
|
|
160
|
+
else:
|
|
161
|
+
skippedPlainCount += 1
|
|
162
|
+
decrypted.append(item)
|
|
163
|
+
|
|
164
|
+
expectedPlainCount = 0
|
|
165
|
+
if len(deduplicated) == 4:
|
|
166
|
+
expectedPlainCount = 1
|
|
167
|
+
|
|
168
|
+
skippedPlain = decryptError or skippedPlainCount != expectedPlainCount
|
|
169
|
+
|
|
170
|
+
return decrypted, skippedPlain
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# main
|
|
174
|
+
def validateImage(imageInput: ImageInput, privKeyPath=None):
|
|
175
|
+
"""
|
|
176
|
+
Extract the embedded payload from an image and optionally decrypt it.
|
|
177
|
+
|
|
178
|
+
Args:
|
|
179
|
+
imageInput: File path, bytes, or file-like object accepted by SimpleImage.
|
|
180
|
+
privKeyPath: Optional path to a PEM-encoded RSA private key used to
|
|
181
|
+
decrypt the extracted ciphertext.
|
|
182
|
+
|
|
183
|
+
Returns:
|
|
184
|
+
Dict with the most common extracted string, decrypted sequence, and
|
|
185
|
+
a validation report describing the sentinel checks and verdict.
|
|
186
|
+
"""
|
|
187
|
+
resultBinary = readHiddenBit(imageInput)
|
|
188
|
+
resultString = binaryToString(resultBinary)
|
|
189
|
+
splited = resultString.split("\n")
|
|
190
|
+
deduplicated, most_common = deduplicate(splited)
|
|
191
|
+
|
|
192
|
+
if privKeyPath:
|
|
193
|
+
decrypted, skippedPlain = decrypt_array(deduplicated, privKeyPath)
|
|
194
|
+
else:
|
|
195
|
+
decrypted = deduplicated
|
|
196
|
+
skippedPlain = False
|
|
197
|
+
|
|
198
|
+
report = buildValidationReport(
|
|
199
|
+
decrypted=decrypted, tailCheck=tailCheck(deduplicated), skipPlain=skippedPlain
|
|
200
|
+
)
|
|
201
|
+
|
|
202
|
+
return {
|
|
203
|
+
"extractedString": decrypt_array({most_common}, privKeyPath)[0][0],
|
|
204
|
+
"decrypted": decrypted,
|
|
205
|
+
"validationReport": report,
|
|
206
|
+
}
|
Pixseal/simpleImage.py
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Loader that prefers the Cython implementation and falls back to pure Python.
|
|
3
|
+
Use environment variable PIXSEAL_SIMPLEIMAGE_BACKEND to force a backend:
|
|
4
|
+
- "cython": require compiled extension
|
|
5
|
+
- "python": force pure Python implementation
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from os import getenv
|
|
9
|
+
|
|
10
|
+
_backend = getenv("PIXSEAL_SIMPLEIMAGE_BACKEND", "auto").lower()
|
|
11
|
+
_loaded_module = "Pixseal.simpleImage_py"
|
|
12
|
+
|
|
13
|
+
if _backend == "python":
|
|
14
|
+
from . import simpleImage_py as _impl # type: ignore
|
|
15
|
+
elif _backend == "cython":
|
|
16
|
+
try:
|
|
17
|
+
from . import simpleImage_ext as _impl # type: ignore
|
|
18
|
+
except ImportError as exc: # pragma: no cover
|
|
19
|
+
raise ImportError(
|
|
20
|
+
"Cython backend requested but Pixseal.simpleImage_ext is not available. "
|
|
21
|
+
"Build the extension or choose the python backend."
|
|
22
|
+
) from exc
|
|
23
|
+
else:
|
|
24
|
+
try: # pragma: no cover
|
|
25
|
+
from . import simpleImage_ext as _impl # type: ignore
|
|
26
|
+
except ImportError: # pragma: no cover
|
|
27
|
+
from . import simpleImage_py as _impl # type: ignore
|
|
28
|
+
|
|
29
|
+
SimpleImage = _impl.SimpleImage # type: ignore
|
|
30
|
+
ImageInput = _impl.ImageInput # type: ignore
|
|
31
|
+
_loaded_module = _impl.__name__
|
|
32
|
+
|
|
33
|
+
print(f"[SimpleImage] Loaded from: {_loaded_module}")
|
|
34
|
+
|
|
35
|
+
__all__ = ["SimpleImage", "ImageInput"]
|
|
Binary file
|
|
@@ -0,0 +1,319 @@
|
|
|
1
|
+
import struct
|
|
2
|
+
import zlib
|
|
3
|
+
from io import BytesIO
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import List, Sequence, Tuple, Union
|
|
6
|
+
|
|
7
|
+
PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def _paethPredictor(a: int, b: int, c: int) -> int:
|
|
11
|
+
p = a + b - c
|
|
12
|
+
pa = abs(p - a)
|
|
13
|
+
pb = abs(p - b)
|
|
14
|
+
pc = abs(p - c)
|
|
15
|
+
if pa <= pb and pa <= pc:
|
|
16
|
+
return a
|
|
17
|
+
if pb <= pc:
|
|
18
|
+
return b
|
|
19
|
+
return c
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _applyPngFilter(
|
|
23
|
+
filterType: int, rowData: bytearray, prevRow: Sequence[int], bytesPerPixel: int
|
|
24
|
+
) -> bytearray:
|
|
25
|
+
recon = bytearray(len(rowData))
|
|
26
|
+
for i in range(len(rowData)):
|
|
27
|
+
left = recon[i - bytesPerPixel] if i >= bytesPerPixel else 0
|
|
28
|
+
up = prevRow[i] if prevRow else 0
|
|
29
|
+
upLeft = prevRow[i - bytesPerPixel] if (prevRow and i >= bytesPerPixel) else 0
|
|
30
|
+
|
|
31
|
+
if filterType == 0:
|
|
32
|
+
recon[i] = rowData[i]
|
|
33
|
+
elif filterType == 1:
|
|
34
|
+
recon[i] = (rowData[i] + left) & 0xFF
|
|
35
|
+
elif filterType == 2:
|
|
36
|
+
recon[i] = (rowData[i] + up) & 0xFF
|
|
37
|
+
elif filterType == 3:
|
|
38
|
+
recon[i] = (rowData[i] + ((left + up) >> 1)) & 0xFF
|
|
39
|
+
elif filterType == 4:
|
|
40
|
+
recon[i] = (rowData[i] + _paethPredictor(left, up, upLeft)) & 0xFF
|
|
41
|
+
else:
|
|
42
|
+
raise ValueError(f"Unsupported PNG filter: {filterType}")
|
|
43
|
+
return recon
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _readChunk(stream) -> Tuple[bytes, bytes]:
|
|
47
|
+
lengthBytes = stream.read(4)
|
|
48
|
+
if len(lengthBytes) == 0:
|
|
49
|
+
return b"", b""
|
|
50
|
+
if len(lengthBytes) != 4:
|
|
51
|
+
raise ValueError("Unexpected EOF while reading chunk length")
|
|
52
|
+
length = struct.unpack(">I", lengthBytes)[0]
|
|
53
|
+
chunkType = stream.read(4)
|
|
54
|
+
if len(chunkType) != 4:
|
|
55
|
+
raise ValueError("Unexpected EOF while reading chunk type")
|
|
56
|
+
data = stream.read(length)
|
|
57
|
+
if len(data) != length:
|
|
58
|
+
raise ValueError("Unexpected EOF while reading chunk data")
|
|
59
|
+
crc = stream.read(4)
|
|
60
|
+
if len(crc) != 4:
|
|
61
|
+
raise ValueError("Unexpected EOF while reading chunk CRC")
|
|
62
|
+
expectedCrc = zlib.crc32(chunkType)
|
|
63
|
+
expectedCrc = zlib.crc32(data, expectedCrc) & 0xFFFFFFFF
|
|
64
|
+
actualCrc = struct.unpack(">I", crc)[0]
|
|
65
|
+
if actualCrc != expectedCrc:
|
|
66
|
+
raise ValueError("Corrupted PNG chunk detected")
|
|
67
|
+
return chunkType, data
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _loadPng(stream) -> Tuple[int, int, bytearray]:
|
|
71
|
+
signature = stream.read(8)
|
|
72
|
+
if signature != PNG_SIGNATURE:
|
|
73
|
+
raise ValueError("Unsupported PNG signature")
|
|
74
|
+
|
|
75
|
+
width = height = None
|
|
76
|
+
bitDepth = colorType = None
|
|
77
|
+
compression = filterMethod = interlace = None
|
|
78
|
+
idatChunks: List[bytes] = []
|
|
79
|
+
|
|
80
|
+
while True:
|
|
81
|
+
chunkType, data = _readChunk(stream)
|
|
82
|
+
if chunkType == b"":
|
|
83
|
+
break
|
|
84
|
+
if chunkType == b"IHDR":
|
|
85
|
+
(
|
|
86
|
+
width,
|
|
87
|
+
height,
|
|
88
|
+
bitDepth,
|
|
89
|
+
colorType,
|
|
90
|
+
compression,
|
|
91
|
+
filterMethod,
|
|
92
|
+
interlace,
|
|
93
|
+
) = struct.unpack(">IIBBBBB", data)
|
|
94
|
+
elif chunkType == b"IDAT":
|
|
95
|
+
idatChunks.append(data)
|
|
96
|
+
elif chunkType == b"IEND":
|
|
97
|
+
break
|
|
98
|
+
|
|
99
|
+
if None in (
|
|
100
|
+
width,
|
|
101
|
+
height,
|
|
102
|
+
bitDepth,
|
|
103
|
+
colorType,
|
|
104
|
+
compression,
|
|
105
|
+
filterMethod,
|
|
106
|
+
interlace,
|
|
107
|
+
):
|
|
108
|
+
raise ValueError("Incomplete PNG header information")
|
|
109
|
+
if bitDepth != 8:
|
|
110
|
+
raise ValueError("Only 8-bit PNG images are supported")
|
|
111
|
+
if colorType not in (2, 6):
|
|
112
|
+
raise ValueError("Only RGB/RGBA PNG images are supported")
|
|
113
|
+
if compression != 0 or filterMethod != 0 or interlace != 0:
|
|
114
|
+
raise ValueError("Unsupported PNG configuration (compression/filter/interlace)")
|
|
115
|
+
|
|
116
|
+
rawImage = zlib.decompress(b"".join(idatChunks))
|
|
117
|
+
bytesPerPixel = 3 if colorType == 2 else 4
|
|
118
|
+
rowLength = width * bytesPerPixel
|
|
119
|
+
expected = height * (rowLength + 1)
|
|
120
|
+
if len(rawImage) != expected:
|
|
121
|
+
raise ValueError("Malformed PNG image data")
|
|
122
|
+
|
|
123
|
+
pixels = bytearray(width * height * 3)
|
|
124
|
+
prevRow = bytearray(rowLength)
|
|
125
|
+
offset = 0
|
|
126
|
+
for y in range(height):
|
|
127
|
+
filterType = rawImage[offset]
|
|
128
|
+
offset += 1
|
|
129
|
+
rowBytes = bytearray(rawImage[offset : offset + rowLength])
|
|
130
|
+
offset += rowLength
|
|
131
|
+
recon = _applyPngFilter(filterType, rowBytes, prevRow, bytesPerPixel)
|
|
132
|
+
for x in range(width):
|
|
133
|
+
srcIndex = x * bytesPerPixel
|
|
134
|
+
destIndex = (y * width + x) * 3
|
|
135
|
+
pixels[destIndex] = recon[srcIndex]
|
|
136
|
+
pixels[destIndex + 1] = recon[srcIndex + 1]
|
|
137
|
+
pixels[destIndex + 2] = recon[srcIndex + 2]
|
|
138
|
+
prevRow = recon
|
|
139
|
+
return width, height, pixels
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def _loadBmp(stream) -> Tuple[int, int, bytearray]:
|
|
143
|
+
header = stream.read(14)
|
|
144
|
+
if len(header) != 14 or header[:2] != b"BM":
|
|
145
|
+
raise ValueError("Unsupported BMP header")
|
|
146
|
+
fileSize, _, _, pixelOffset = struct.unpack("<IHHI", header[2:])
|
|
147
|
+
dibHeaderSizeBytes = stream.read(4)
|
|
148
|
+
if len(dibHeaderSizeBytes) != 4:
|
|
149
|
+
raise ValueError("Corrupted BMP DIB header")
|
|
150
|
+
dibHeaderSize = struct.unpack("<I", dibHeaderSizeBytes)[0]
|
|
151
|
+
if dibHeaderSize != 40:
|
|
152
|
+
raise ValueError("Only BITMAPINFOHEADER BMP files are supported")
|
|
153
|
+
dibData = stream.read(36)
|
|
154
|
+
(
|
|
155
|
+
width,
|
|
156
|
+
height,
|
|
157
|
+
planes,
|
|
158
|
+
bitCount,
|
|
159
|
+
compression,
|
|
160
|
+
imageSize,
|
|
161
|
+
xPpm,
|
|
162
|
+
yPpm,
|
|
163
|
+
clrUsed,
|
|
164
|
+
clrImportant,
|
|
165
|
+
) = struct.unpack("<iiHHIIiiII", dibData)
|
|
166
|
+
if planes != 1 or bitCount != 24 or compression != 0:
|
|
167
|
+
raise ValueError("Only uncompressed 24-bit BMP files are supported")
|
|
168
|
+
absHeight = abs(height)
|
|
169
|
+
rowStride = ((width * 3 + 3) // 4) * 4
|
|
170
|
+
pixels = bytearray(width * absHeight * 3)
|
|
171
|
+
stream.seek(pixelOffset)
|
|
172
|
+
for row in range(absHeight):
|
|
173
|
+
rowData = stream.read(rowStride)
|
|
174
|
+
if len(rowData) != rowStride:
|
|
175
|
+
raise ValueError("Incomplete BMP pixel data")
|
|
176
|
+
targetRow = absHeight - 1 - row if height > 0 else row
|
|
177
|
+
baseIndex = targetRow * width * 3
|
|
178
|
+
for x in range(width):
|
|
179
|
+
pixelOffsetInRow = x * 3
|
|
180
|
+
b = rowData[pixelOffsetInRow]
|
|
181
|
+
g = rowData[pixelOffsetInRow + 1]
|
|
182
|
+
r = rowData[pixelOffsetInRow + 2]
|
|
183
|
+
dest = baseIndex + x * 3
|
|
184
|
+
pixels[dest] = r & 0xFF
|
|
185
|
+
pixels[dest + 1] = g & 0xFF
|
|
186
|
+
pixels[dest + 2] = b & 0xFF
|
|
187
|
+
return width, absHeight, pixels
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
def _makeChunk(chunkType: bytes, data: bytes) -> bytes:
|
|
191
|
+
length = struct.pack(">I", len(data))
|
|
192
|
+
crcValue = zlib.crc32(chunkType)
|
|
193
|
+
crcValue = zlib.crc32(data, crcValue) & 0xFFFFFFFF
|
|
194
|
+
crc = struct.pack(">I", crcValue)
|
|
195
|
+
return length + chunkType + data + crc
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def _writePng(path: str, width: int, height: int, pixels: Sequence[int]) -> None:
|
|
199
|
+
ihdr = struct.pack(">IIBBBBB", width, height, 8, 2, 0, 0, 0)
|
|
200
|
+
rowStride = width * 3
|
|
201
|
+
raw = bytearray()
|
|
202
|
+
for y in range(height):
|
|
203
|
+
raw.append(0)
|
|
204
|
+
start = y * rowStride
|
|
205
|
+
raw.extend(pixels[start : start + rowStride])
|
|
206
|
+
compressed = zlib.compress(bytes(raw))
|
|
207
|
+
with open(path, "wb") as output:
|
|
208
|
+
output.write(PNG_SIGNATURE)
|
|
209
|
+
output.write(_makeChunk(b"IHDR", ihdr))
|
|
210
|
+
output.write(_makeChunk(b"IDAT", compressed))
|
|
211
|
+
output.write(_makeChunk(b"IEND", b""))
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _writeBmp(path: str, width: int, height: int, pixels: Sequence[int]) -> None:
|
|
215
|
+
rowStride = ((width * 3 + 3) // 4) * 4
|
|
216
|
+
pixelArraySize = rowStride * height
|
|
217
|
+
fileSize = 14 + 40 + pixelArraySize
|
|
218
|
+
with open(path, "wb") as output:
|
|
219
|
+
output.write(b"BM")
|
|
220
|
+
output.write(struct.pack("<IHHI", fileSize, 0, 0, 54))
|
|
221
|
+
output.write(
|
|
222
|
+
struct.pack(
|
|
223
|
+
"<IIIHHIIIIII",
|
|
224
|
+
40,
|
|
225
|
+
width,
|
|
226
|
+
height,
|
|
227
|
+
1,
|
|
228
|
+
24,
|
|
229
|
+
0,
|
|
230
|
+
pixelArraySize,
|
|
231
|
+
2835,
|
|
232
|
+
2835,
|
|
233
|
+
0,
|
|
234
|
+
0,
|
|
235
|
+
)
|
|
236
|
+
)
|
|
237
|
+
rowPad = rowStride - width * 3
|
|
238
|
+
padBytes = b"\x00" * rowPad
|
|
239
|
+
for y in range(height - 1, -1, -1):
|
|
240
|
+
start = y * width * 3
|
|
241
|
+
for x in range(width):
|
|
242
|
+
idx = start + x * 3
|
|
243
|
+
r = pixels[idx]
|
|
244
|
+
g = pixels[idx + 1]
|
|
245
|
+
b = pixels[idx + 2]
|
|
246
|
+
output.write(bytes((b & 0xFF, g & 0xFF, r & 0xFF)))
|
|
247
|
+
if rowPad:
|
|
248
|
+
output.write(padBytes)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
ImageInput = Union[str, Path, bytes, bytearray]
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class SimpleImage:
|
|
255
|
+
__slots__ = ("width", "height", "_pixels")
|
|
256
|
+
|
|
257
|
+
def __init__(self, width: int, height: int, pixels: Sequence[int]):
|
|
258
|
+
self.width = width
|
|
259
|
+
self.height = height
|
|
260
|
+
expected = width * height * 3
|
|
261
|
+
if len(pixels) != expected:
|
|
262
|
+
raise ValueError("Pixel data length does not match image dimensions")
|
|
263
|
+
self._pixels = bytearray(pixels)
|
|
264
|
+
|
|
265
|
+
@property
|
|
266
|
+
def size(self) -> Tuple[int, int]:
|
|
267
|
+
return self.width, self.height
|
|
268
|
+
|
|
269
|
+
@staticmethod
|
|
270
|
+
def _streamToImage(stream) -> Tuple[int, int, bytearray]:
|
|
271
|
+
signature = stream.read(8)
|
|
272
|
+
stream.seek(0)
|
|
273
|
+
if signature.startswith(PNG_SIGNATURE):
|
|
274
|
+
return _loadPng(stream)
|
|
275
|
+
if signature[:2] == b"BM":
|
|
276
|
+
return _loadBmp(stream)
|
|
277
|
+
raise ValueError("Unsupported image format")
|
|
278
|
+
|
|
279
|
+
@classmethod
|
|
280
|
+
def open(cls, source: ImageInput) -> "SimpleImage":
|
|
281
|
+
if isinstance(source, (str, Path)):
|
|
282
|
+
with open(source, "rb") as stream:
|
|
283
|
+
width, height, pixels = cls._streamToImage(stream)
|
|
284
|
+
elif isinstance(source, (bytes, bytearray)):
|
|
285
|
+
stream = BytesIO(source)
|
|
286
|
+
width, height, pixels = cls._streamToImage(stream)
|
|
287
|
+
else:
|
|
288
|
+
raise TypeError("source must be a file path or raw bytes")
|
|
289
|
+
return cls(width, height, pixels)
|
|
290
|
+
|
|
291
|
+
def getPixel(self, coords: Tuple[int, int]) -> Tuple[int, int, int]:
|
|
292
|
+
x, y = coords
|
|
293
|
+
if not (0 <= x < self.width and 0 <= y < self.height):
|
|
294
|
+
raise ValueError("Pixel coordinate out of bounds")
|
|
295
|
+
index = (y * self.width + x) * 3
|
|
296
|
+
return (
|
|
297
|
+
self._pixels[index],
|
|
298
|
+
self._pixels[index + 1],
|
|
299
|
+
self._pixels[index + 2],
|
|
300
|
+
)
|
|
301
|
+
|
|
302
|
+
def putPixel(self, coords: Tuple[int, int], value: Sequence[int]) -> None:
|
|
303
|
+
x, y = coords
|
|
304
|
+
if not (0 <= x < self.width and 0 <= y < self.height):
|
|
305
|
+
raise ValueError("Pixel coordinate out of bounds")
|
|
306
|
+
index = (y * self.width + x) * 3
|
|
307
|
+
r, g, b = value
|
|
308
|
+
self._pixels[index] = int(r) & 0xFF
|
|
309
|
+
self._pixels[index + 1] = int(g) & 0xFF
|
|
310
|
+
self._pixels[index + 2] = int(b) & 0xFF
|
|
311
|
+
|
|
312
|
+
def copy(self) -> "SimpleImage":
|
|
313
|
+
return SimpleImage(self.width, self.height, self._pixels[:])
|
|
314
|
+
|
|
315
|
+
def save(self, path: str) -> None:
|
|
316
|
+
_writePng(path, self.width, self.height, self._pixels)
|
|
317
|
+
|
|
318
|
+
def saveBmp(self, path: str) -> None:
|
|
319
|
+
_writeBmp(path, self.width, self.height, self._pixels)
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Pixseal/__init__.py,sha256=8J9y9waCvvaLBhcK7s6Z_q7yuIKORbuxvt0UPkikJr8,488
|
|
2
|
+
Pixseal/imageSigner.py,sha256=WrHHKCmtdf_qutAd9P5TVaraQEaLkHcSGMl5Rfoq6ng,5979
|
|
3
|
+
Pixseal/imageValidator.py,sha256=YnM5UNRFMqDjUGOVAozBM0LsC7IkqIpXIxEMLLtnIWM,5949
|
|
4
|
+
Pixseal/simpleImage.py,sha256=dkpMEhkT1z0pZOgy-AbgYlF9GC73eM2WGaYb5FiGNpk,1234
|
|
5
|
+
Pixseal/simpleImage_ext.cpython-314-x86_64-linux-gnu.so,sha256=-ugmkXblsIVni84ORQeqe34qf4ATZ74-54yFVQRWxDU,2695488
|
|
6
|
+
Pixseal/simpleImage_py.py,sha256=jJajVT8Y7Cv_hZ_irmCWtVqQwcg66VEb2lhF9u-SYK8,10849
|
|
7
|
+
pixseal-0.2.1.post1.dist-info/METADATA,sha256=pUkqjUSucm1F9AXwyzsL0h6KHjgLsEdkiWHk_ybEWC4,212
|
|
8
|
+
pixseal-0.2.1.post1.dist-info/WHEEL,sha256=DMni2HWXXG4oat8Y6bAVuu-xlkB-grHQiqsTu-lLHls,190
|
|
9
|
+
pixseal-0.2.1.post1.dist-info/top_level.txt,sha256=q35ICL7vJyo5hOpL4YbL5t_g4297gwUPIuLy82kgL3s,8
|
|
10
|
+
pixseal-0.2.1.post1.dist-info/RECORD,,
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Pixseal
|