nt25 0.1.5__tar.gz → 0.1.7__tar.gz

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.
@@ -20,6 +20,6 @@ ds/
20
20
  output/
21
21
 
22
22
  # others
23
- *.jpg
24
- *.png
23
+ tests/*.jpg
24
+ tests/*.png
25
25
 
@@ -1,13 +1,13 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: nt25
3
- Version: 0.1.5
3
+ Version: 0.1.7
4
4
  Summary: Neo's Tools of Python
5
5
  Requires-Python: >=3.10
6
6
  Requires-Dist: exif>=1.6.1
7
7
  Requires-Dist: matplotlib>=3.10.6
8
8
  Requires-Dist: openpyxl>=3.1.5
9
9
  Requires-Dist: pandas>=2.3.2
10
- Requires-Dist: piexif>=1.1.3
10
+ Requires-Dist: pillow>=11.3.0
11
11
  Requires-Dist: pyinstaller>=6.15.0
12
12
  Requires-Dist: scikit-learn>=1.7.1
13
13
  Requires-Dist: sympy>=1.14.0
@@ -1,18 +1,18 @@
1
1
  [project]
2
2
  name = "nt25"
3
- version = "0.1.5"
3
+ version = "0.1.7"
4
4
  description = "Neo's Tools of Python"
5
5
  readme = "README.md"
6
6
  requires-python = ">=3.10"
7
7
  dependencies = [
8
+ "pyinstaller>=6.15.0",
8
9
  "exif>=1.6.1",
9
10
  "matplotlib>=3.10.6",
10
- "openpyxl>=3.1.5",
11
11
  "pandas>=2.3.2",
12
- "piexif>=1.1.3",
13
- "pyinstaller>=6.15.0",
12
+ "openpyxl>=3.1.5",
14
13
  "scikit-learn>=1.7.1",
15
14
  "sympy>=1.14.0",
15
+ "pillow>=11.3.0",
16
16
  ]
17
17
 
18
18
  [project.scripts]
Binary file
@@ -0,0 +1,333 @@
1
+ import io
2
+ import os
3
+ import time
4
+ import json
5
+ import struct
6
+ import argparse
7
+
8
+ from PIL import Image as pi
9
+
10
+ from datetime import UTC, datetime, timedelta, timezone
11
+ from exif import Image, DATETIME_STR_FORMAT
12
+
13
+ VERSION = "0.1.3"
14
+ COMMENT_SEGMENT = b"\xff\xfe"
15
+ EPOCH = datetime.fromtimestamp(0, UTC)
16
+
17
+
18
+ def dms2dec(dms: tuple):
19
+ d, m, s = dms
20
+ return d + m/60 + s/3600
21
+
22
+
23
+ def dtFormatter(str):
24
+ return datetime.strptime(str, DATETIME_STR_FORMAT)
25
+
26
+
27
+ def dt2str(dt):
28
+ return None if dt is None else dt.strftime(DATETIME_STR_FORMAT)
29
+
30
+
31
+ def gpsDt2Dt(date, time, offset=8):
32
+ d = dtFormatter(f"{date} {int(time[0])}:{int(time[1])}:{int(time[2])}")
33
+ utc = d.replace(tzinfo=timezone.utc)
34
+ return utc.astimezone(timezone(timedelta(hours=offset)))
35
+
36
+
37
+ def optimizeFile(file, q=80):
38
+ less = False
39
+ ofile = file + '.jpg'
40
+ pi.open(file).save(ofile, quality=q, optimize=True, progression=True)
41
+
42
+ if os.path.getsize(ofile) < os.path.getsize(file) * 0.9:
43
+ less = True
44
+ transplant(file, ofile)
45
+ os.replace(ofile, file)
46
+ else:
47
+ os.remove(ofile)
48
+
49
+ return less
50
+
51
+
52
+ def tryGet(img, key, default):
53
+ value = default
54
+
55
+ try:
56
+ value = img[key]
57
+ except Exception:
58
+ pass
59
+
60
+ return value
61
+
62
+
63
+ def dumpExif(file, optimize=False):
64
+ result = {}
65
+
66
+ if optimize:
67
+ optimizeFile(file)
68
+
69
+ with open(file, 'rb') as f:
70
+ img = Image(f)
71
+ for key in img.get_all():
72
+ try:
73
+ result[key] = str(img[key])
74
+ except Exception:
75
+ pass
76
+
77
+ return result
78
+
79
+
80
+ def parseExif(file, optimize=False):
81
+ if optimize:
82
+ optimizeFile(file)
83
+
84
+ with open(file, 'rb') as f:
85
+ try:
86
+ img = Image(f)
87
+ except Exception:
88
+ return {}
89
+
90
+ width = tryGet(img, 'pixel_x_dimension', -1)
91
+ height = tryGet(img, 'pixel_y_dimension', -1)
92
+
93
+ if width < 0:
94
+ width = tryGet(img, 'image_width', -1)
95
+ height = tryGet(img, 'image_height', -1)
96
+
97
+ create = tryGet(img, 'datetime_original', None)
98
+ modify = tryGet(img, 'datetime', None)
99
+
100
+ createDt = None if create is None else dtFormatter(create)
101
+ modifyDt = None if modify is None else dtFormatter(modify)
102
+
103
+ latitude = tryGet(img, 'gps_latitude', None)
104
+ latitude = None if latitude is None else dms2dec(latitude)
105
+
106
+ latRef = tryGet(img, "gps_latitude_ref", default='N')
107
+ if latRef != 'N' and latitude:
108
+ latitude = -latitude
109
+
110
+ longitude = tryGet(img, 'gps_longitude', None)
111
+ longitude = None if longitude is None else dms2dec(longitude)
112
+
113
+ longRef = tryGet(img, "gps_longitude_ref", default='E')
114
+ if longRef != 'E' and longitude:
115
+ longitude = -longitude
116
+
117
+ gpsDatetime = None
118
+ gd = tryGet(img, 'gps_datestamp', None)
119
+ gt = tryGet(img, 'gps_timestamp', None)
120
+
121
+ if gd and gt:
122
+ offset = int(time.localtime().tm_gmtoff / 3600)
123
+ gpsDatetime = gpsDt2Dt(gd, gt, offset=offset)
124
+
125
+ ts = -1 if createDt is None else int(createDt.timestamp())
126
+ mTs = -1 if modifyDt is None else int(modifyDt.timestamp())
127
+ gpsTs = -1 if gpsDatetime is None else int(gpsDatetime.timestamp())
128
+
129
+ if ts > 0:
130
+ offset = max(mTs, gpsTs) - ts
131
+ offsetDelta = str(datetime.fromtimestamp(offset, UTC) - EPOCH)
132
+ else:
133
+ offset = None
134
+ offsetDelta = None
135
+
136
+ return {
137
+ "width": width,
138
+ "height": height,
139
+ "latitude": latitude,
140
+ "longitude": longitude,
141
+ "datetime.create": dt2str(createDt),
142
+ "datetime.modify": dt2str(modifyDt),
143
+ "datetime.gps": dt2str(gpsDatetime),
144
+ "ts": ts,
145
+ "offset": offset,
146
+ "offset.delta": offsetDelta,
147
+ }
148
+
149
+
150
+ class InvalidImageDataError(ValueError):
151
+ pass
152
+
153
+
154
+ def genSegments(data):
155
+ if data[0:2] != b"\xff\xd8":
156
+ raise InvalidImageDataError("Given data isn't JPEG.")
157
+
158
+ head = 2
159
+ segments = [b"\xff\xd8"]
160
+
161
+ while 1:
162
+ if data[head: head + 2] == b"\xff\xda":
163
+ segments.append(data[head:])
164
+ break
165
+
166
+ else:
167
+ length = struct.unpack(">H", data[head + 2: head + 4])[0]
168
+ endPoint = head + length + 2
169
+ seg = data[head: endPoint]
170
+ segments.append(seg)
171
+ head = endPoint
172
+
173
+ if (head >= len(data)):
174
+ raise InvalidImageDataError("Wrong JPEG data.")
175
+
176
+ return segments
177
+
178
+
179
+ def setComment(segments, comment: str, enc='utf-8'):
180
+ contains = False
181
+ cb = comment.encode(enc)
182
+ length = len(cb) + 2
183
+
184
+ cbSeg = COMMENT_SEGMENT + length.to_bytes(2, byteorder='big') + cb
185
+
186
+ for i in range(len(segments)):
187
+ if segments[i][0:2] == COMMENT_SEGMENT:
188
+ contains = True
189
+ segments[i] = cbSeg
190
+
191
+ if not contains:
192
+ length = len(segments)
193
+ segments.insert(1 if length == 2 else length - 2, cbSeg)
194
+
195
+ return segments
196
+
197
+
198
+ def getComment(segments, enc='utf-8'):
199
+ for seg in segments:
200
+ if seg[0:2] == COMMENT_SEGMENT:
201
+ return seg[4:].decode(encoding=enc, errors='replace')
202
+
203
+ return None
204
+
205
+
206
+ def getExif(segments):
207
+ """Returns Exif from JPEG meta data list
208
+ """
209
+ for seg in segments:
210
+ if seg[0:2] == b"\xff\xe1" and seg[4:10] == b"Exif\x00\x00":
211
+ return seg
212
+
213
+ return b""
214
+
215
+
216
+ def mergeSegments(segments, exif=b""):
217
+ """Merges Exif with APP0 and APP1 manipulations.
218
+ """
219
+ if segments[1][0:2] == b"\xff\xe0" and \
220
+ segments[2][0:2] == b"\xff\xe1" and \
221
+ segments[2][4:10] == b"Exif\x00\x00":
222
+ if exif:
223
+ segments[2] = exif
224
+ segments.pop(1)
225
+ elif exif is None:
226
+ segments.pop(2)
227
+ else:
228
+ segments.pop(1)
229
+
230
+ elif segments[1][0:2] == b"\xff\xe0":
231
+ if exif:
232
+ segments[1] = exif
233
+
234
+ elif (segments[1][0:2] == b"\xff\xe1" and
235
+ segments[1][4:10] == b"Exif\x00\x00"):
236
+
237
+ if exif:
238
+ segments[1] = exif
239
+ elif exif is None:
240
+ segments.pop(1)
241
+
242
+ else:
243
+ if exif:
244
+ segments.insert(1, exif)
245
+
246
+ return b"".join(segments)
247
+
248
+
249
+ def removeExif(src, optimize=False):
250
+ if optimize:
251
+ optimizeFile(src)
252
+
253
+ with open(src, 'rb') as f:
254
+ src_data = f.read()
255
+
256
+ segments = genSegments(src_data)
257
+ segments = list(filter(lambda seg: not (seg[0:2] == b"\xff\xe1"
258
+ and seg[4:10] == b"Exif\x00\x00"),
259
+ segments))
260
+
261
+ segments = setComment(segments, "nt25.et")
262
+ new_data = b"".join(segments)
263
+
264
+ with open(src, "wb+") as f:
265
+ f.write(new_data)
266
+
267
+
268
+ def transplant(exif_src, image, optimize=False):
269
+ if optimize:
270
+ optimizeFile(image)
271
+
272
+ if exif_src[0:2] == b"\xff\xd8":
273
+ src_data = exif_src
274
+ else:
275
+ with open(exif_src, 'rb') as f:
276
+ src_data = f.read()
277
+
278
+ segments = genSegments(src_data)
279
+ exif = getExif(segments)
280
+
281
+ with open(image, 'rb') as f:
282
+ image_data = f.read()
283
+
284
+ segments = genSegments(image_data)
285
+ segments = setComment(segments, "nt25.et")
286
+ new_data = mergeSegments(segments, exif)
287
+
288
+ with open(image, "wb+") as f:
289
+ f.write(new_data)
290
+
291
+
292
+ def main():
293
+ parser = argparse.ArgumentParser(description="EXIF tool")
294
+ parser.add_argument('-v', '--version', action='store_true',
295
+ help='echo version')
296
+ parser.add_argument('-o', '--optimize', action='store_true',
297
+ help='optimize jpg file, work with -r, -d, -c, -f')
298
+ parser.add_argument('-r', '--rm', action='store_true',
299
+ help='remove meta, use: -r -f FILE')
300
+ parser.add_argument('-d', '--dump', action='store_true',
301
+ help='dump meta, use: -d -f FILE')
302
+ parser.add_argument('-c', '--copy', type=str,
303
+ help='copy meta, use: -c SRC -f DST')
304
+ parser.add_argument('-f', '--file', type=str, help='image file')
305
+
306
+ args = parser.parse_args()
307
+
308
+ if args.version:
309
+ print(f"et: {VERSION}")
310
+ return
311
+
312
+ if args.file is None:
313
+ print("usage: et [-h] [-v] [-o] [-f FILE] [-d -f FILE]\n"
314
+ "\t\t[-r -f FILE] [-c SRC -f DST]")
315
+ return
316
+
317
+ opt = True if args.optimize else False
318
+
319
+ if args.dump:
320
+ r = dumpExif(args.file, optimize=opt)
321
+ elif args.rm:
322
+ r = removeExif(args.file, optimize=opt)
323
+ elif args.copy:
324
+ r = transplant(args.copy, args.file, optimize=opt)
325
+ else:
326
+ r = parseExif(args.file, optimize=opt)
327
+
328
+ if r is not None:
329
+ print(json.dumps(r, indent=2, sort_keys=False))
330
+
331
+
332
+ if __name__ == "__main__":
333
+ main()
@@ -1,333 +0,0 @@
1
- import io
2
- import time
3
- import json
4
- import struct
5
- import argparse
6
-
7
- from datetime import UTC, datetime, timedelta, timezone
8
- from exif import Image, DATETIME_STR_FORMAT
9
-
10
- VERSION = "0.1.2"
11
- EPOCH = datetime.fromtimestamp(0, UTC)
12
-
13
-
14
- def dms2dec(dms: tuple):
15
- d, m, s = dms
16
- return d + m/60 + s/3600
17
-
18
-
19
- def dtFormatter(str):
20
- return datetime.strptime(str, DATETIME_STR_FORMAT)
21
-
22
-
23
- def dt2str(dt):
24
- return None if dt is None else dt.strftime(DATETIME_STR_FORMAT)
25
-
26
-
27
- def gpsDt2Dt(date, time, offset=8):
28
- d = dtFormatter(f"{date} {int(time[0])}:{int(time[1])}:{int(time[2])}")
29
- utc = d.replace(tzinfo=timezone.utc)
30
- return utc.astimezone(timezone(timedelta(hours=offset)))
31
-
32
-
33
- def tryGet(img, key, default):
34
- value = default
35
-
36
- try:
37
- value = img[key]
38
- except Exception:
39
- pass
40
-
41
- return value
42
-
43
-
44
- def dumpExif(file):
45
- result = {}
46
- with open(file, 'rb') as f:
47
- img = Image(f)
48
- for key in img.get_all():
49
- try:
50
- result[key] = str(img[key])
51
- except Exception:
52
- pass
53
-
54
- return result
55
-
56
-
57
- def parseExif(file):
58
- with open(file, 'rb') as f:
59
- try:
60
- img = Image(f)
61
- except Exception:
62
- return {}
63
-
64
- width = tryGet(img, 'pixel_x_dimension', -1)
65
- height = tryGet(img, 'pixel_y_dimension', -1)
66
-
67
- if width < 0:
68
- width = tryGet(img, 'image_width', -1)
69
- height = tryGet(img, 'image_height', -1)
70
-
71
- create = tryGet(img, 'datetime_original', None)
72
- modify = tryGet(img, 'datetime', None)
73
-
74
- createDt = None if create is None else dtFormatter(create)
75
- modifyDt = None if modify is None else dtFormatter(modify)
76
-
77
- latitude = tryGet(img, 'gps_latitude', None)
78
- latitude = None if latitude is None else dms2dec(latitude)
79
-
80
- longitude = tryGet(img, 'gps_longitude', None)
81
- longitude = None if longitude is None else dms2dec(longitude)
82
-
83
- gpsDatetime = None
84
- gd = tryGet(img, 'gps_datestamp', None)
85
- gt = tryGet(img, 'gps_timestamp', None)
86
-
87
- if gd and gt:
88
- offset = int(time.localtime().tm_gmtoff / 3600)
89
- gpsDatetime = gpsDt2Dt(gd, gt, offset=offset)
90
-
91
- ts = -1 if createDt is None else int(createDt.timestamp())
92
- mTs = -1 if modifyDt is None else int(modifyDt.timestamp())
93
- gpsTs = -1 if gpsDatetime is None else int(gpsDatetime.timestamp())
94
- offset = max(mTs, gpsTs) - ts
95
- offsetDelta = datetime.fromtimestamp(offset, UTC) - EPOCH
96
-
97
- return {
98
- "width": width,
99
- "height": height,
100
- "latitude": latitude,
101
- "longitude": longitude,
102
- "datetime.create": dt2str(createDt),
103
- "datetime.modify": dt2str(modifyDt),
104
- "datetime.gps": dt2str(gpsDatetime),
105
- "ts": ts,
106
- "offset": offset,
107
- "offset.delta": str(offsetDelta),
108
- }
109
-
110
-
111
- class InvalidImageDataError(ValueError):
112
- pass
113
-
114
-
115
- def split_into_segments(data):
116
- """Slices JPEG meta data into a list from JPEG binary data.
117
- """
118
- if data[0:2] != b"\xff\xd8":
119
- raise InvalidImageDataError("Given data isn't JPEG.")
120
-
121
- head = 2
122
- segments = [b"\xff\xd8"]
123
- while 1:
124
- if data[head: head + 2] == b"\xff\xda":
125
- segments.append(data[head:])
126
- break
127
- else:
128
- length = struct.unpack(">H", data[head + 2: head + 4])[0]
129
- endPoint = head + length + 2
130
- seg = data[head: endPoint]
131
- segments.append(seg)
132
- head = endPoint
133
-
134
- if (head >= len(data)):
135
- raise InvalidImageDataError("Wrong JPEG data.")
136
-
137
- return segments
138
-
139
-
140
- def read_exif_from_file(filename):
141
- """Slices JPEG meta data into a list from JPEG binary data.
142
- """
143
- f = open(filename, "rb")
144
- data = f.read(6)
145
-
146
- if data[0:2] != b"\xff\xd8":
147
- raise InvalidImageDataError("Given data isn't JPEG.")
148
-
149
- head = data[2:6]
150
- HEAD_LENGTH = 4
151
- exif = None
152
- while len(head) == HEAD_LENGTH:
153
- length = struct.unpack(">H", head[2: 4])[0]
154
-
155
- if head[:2] == b"\xff\xe1":
156
- segment_data = f.read(length - 2)
157
- if segment_data[:4] != b'Exif':
158
- head = f.read(HEAD_LENGTH)
159
- continue
160
- exif = head + segment_data
161
- break
162
- elif head[0:1] == b"\xff":
163
- f.read(length - 2)
164
- head = f.read(HEAD_LENGTH)
165
- else:
166
- break
167
-
168
- f.close()
169
- return exif
170
-
171
-
172
- def get_exif_seg(segments):
173
- """Returns Exif from JPEG meta data list
174
- """
175
- for seg in segments:
176
- if seg[0:2] == b"\xff\xe1" and seg[4:10] == b"Exif\x00\x00":
177
- return seg
178
-
179
- return None
180
-
181
-
182
- def merge_segments(segments, exif=b""):
183
- """Merges Exif with APP0 and APP1 manipulations.
184
- """
185
- if segments[1][0:2] == b"\xff\xe0" and \
186
- segments[2][0:2] == b"\xff\xe1" and \
187
- segments[2][4:10] == b"Exif\x00\x00":
188
- if exif:
189
- segments[2] = exif
190
- segments.pop(1)
191
- elif exif is None:
192
- segments.pop(2)
193
- else:
194
- segments.pop(1)
195
-
196
- elif segments[1][0:2] == b"\xff\xe0":
197
- if exif:
198
- segments[1] = exif
199
-
200
- elif (segments[1][0:2] == b"\xff\xe1" and
201
- segments[1][4:10] == b"Exif\x00\x00"):
202
-
203
- if exif:
204
- segments[1] = exif
205
- elif exif is None:
206
- segments.pop(1)
207
-
208
- else:
209
- if exif:
210
- segments.insert(1, exif)
211
-
212
- return b"".join(segments)
213
-
214
-
215
- def remove(src, new_file=None):
216
- """
217
- py:function:: piexif.remove(filename)
218
-
219
- Remove exif from JPEG.
220
-
221
- :param str filename: JPEG
222
- """
223
- output_is_file = False
224
- if src[0:2] == b"\xff\xd8":
225
- src_data = src
226
- file_type = "jpeg"
227
- else:
228
- with open(src, 'rb') as f:
229
- src_data = f.read()
230
- output_is_file = True
231
- if src_data[0:2] == b"\xff\xd8":
232
- file_type = "jpeg"
233
-
234
- if file_type == "jpeg":
235
- segments = split_into_segments(src_data)
236
- exif = get_exif_seg(segments)
237
- if exif:
238
- new_data = src_data.replace(exif, b"")
239
- else:
240
- new_data = src_data
241
-
242
- if isinstance(new_file, io.BytesIO):
243
- new_file.write(new_data)
244
- new_file.seek(0)
245
- elif new_file:
246
- with open(new_file, "wb+") as f:
247
- f.write(new_data)
248
- elif output_is_file:
249
- with open(src, "wb+") as f:
250
- f.write(new_data)
251
- else:
252
- raise ValueError("Give a second argument to 'remove' to output file")
253
-
254
-
255
- def transplant(exif_src, image, new_file=None):
256
- """
257
- py:function:: piexif.transplant(filename1, filename2)
258
-
259
- Transplant exif from filename1 to filename2.
260
-
261
- :param str filename1: JPEG
262
- :param str filename2: JPEG
263
- """
264
- if exif_src[0:2] == b"\xff\xd8":
265
- src_data = exif_src
266
- else:
267
- with open(exif_src, 'rb') as f:
268
- src_data = f.read()
269
-
270
- segments = split_into_segments(src_data)
271
- exif = get_exif_seg(segments)
272
-
273
- if exif is None:
274
- raise ValueError("not found exif in input")
275
-
276
- output_file = False
277
- if image[0:2] == b"\xff\xd8":
278
- image_data = image
279
- else:
280
- with open(image, 'rb') as f:
281
- image_data = f.read()
282
- output_file = True
283
-
284
- segments = split_into_segments(image_data)
285
- new_data = merge_segments(segments, exif)
286
-
287
- if isinstance(new_file, io.BytesIO):
288
- new_file.write(new_data)
289
- new_file.seek(0)
290
- elif new_file:
291
- with open(new_file, "wb+") as f:
292
- f.write(new_data)
293
- elif output_file:
294
- with open(image, "wb+") as f:
295
- f.write(new_data)
296
- else:
297
- raise ValueError("Give a 3rd argument to 'transplant' to output file")
298
-
299
-
300
- def main():
301
- parser = argparse.ArgumentParser(description="EXIF tool")
302
- parser.add_argument('-v', '--version',
303
- help='echo version', action='store_true')
304
- parser.add_argument('-d', '--dump', help='dump meta', action='store_true')
305
- parser.add_argument('-r', '--rm', help='remove meta', action='store_true')
306
- parser.add_argument('-c', '--copy', type=str, help='copy meta')
307
- parser.add_argument('-f', '--file', type=str, help='image file')
308
-
309
- args = parser.parse_args()
310
-
311
- if args.version:
312
- print(f"et: {VERSION}")
313
- return
314
-
315
- if args.file is None:
316
- print("usage: et [-h] [-v] [-r FILE] [-c FILE] [-d] [-f FILE]")
317
- return
318
-
319
- if args.dump:
320
- r = dumpExif(args.file)
321
- elif args.rm:
322
- r = remove(args.file)
323
- elif args.copy:
324
- r = transplant(args.copy, args.file)
325
- else:
326
- r = parseExif(args.file)
327
-
328
- if r is not None:
329
- print(json.dumps(r, indent=2, sort_keys=False))
330
-
331
-
332
- if __name__ == "__main__":
333
- main()
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes