namelessai commited on
Commit
7d243fc
·
verified ·
1 Parent(s): d6c54f7

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +381 -0
app.py ADDED
@@ -0,0 +1,381 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import io
2
+ import os
3
+ from typing import Optional, Tuple
4
+
5
+ import gradio as gr
6
+ import numpy as np
7
+ from PIL import Image, ImageCms, ImageOps, ImageFile, features
8
+
9
+ # Ensure PIL can load large images
10
+ ImageFile.LOAD_TRUNCATED_IMAGES = True
11
+
12
+
13
+ def detect_features() -> dict:
14
+ """Detect optional codec features available in this runtime."""
15
+ feats = {
16
+ "jpeg": features.check("jpg"),
17
+ "jpeg_2000": features.check("jpg_2000"),
18
+ "imagecms": hasattr(Image, "cms") or True, # PIL bundles lcms
19
+ }
20
+ return feats
21
+
22
+
23
+ RUNTIME_FEATURES = detect_features()
24
+
25
+
26
+ def flatten_transparency_to_background(img: Image.Image, background_rgb: Tuple[int, int, int]) -> Image.Image:
27
+ """Flatten any alpha channel against a background color to get an opaque RGB image."""
28
+ if img.mode in ("RGBA", "LA") or (img.mode == "P" and "transparency" in img.info):
29
+ # Convert to RGBA to ensure alpha channel exists for compositing
30
+ rgba = img.convert("RGBA")
31
+ background = Image.new("RGBA", rgba.size, background_rgb + (255,))
32
+ composited = Image.alpha_composite(background, rgba)
33
+ return composited.convert("RGB")
34
+ # No alpha present
35
+ if img.mode != "RGB":
36
+ return img.convert("RGB")
37
+ return img
38
+
39
+
40
+ def apply_resize(img: Image.Image, max_width: Optional[int], max_height: Optional[int], resize_method: str) -> Image.Image:
41
+ if not max_width and not max_height:
42
+ return img
43
+ width, height = img.size
44
+ target_w = max_width or width
45
+ target_h = max_height or height
46
+
47
+ # Compute scale while preserving aspect ratio
48
+ scale = min(target_w / width, target_h / height)
49
+ if scale >= 1.0:
50
+ return img
51
+
52
+ new_size = (max(1, int(width * scale)), max(1, int(height * scale)))
53
+
54
+ resample_map = {
55
+ "Lanczos (best)": Image.Resampling.LANCZOS,
56
+ "Bicubic": Image.Resampling.BICUBIC,
57
+ "Bilinear": Image.Resampling.BILINEAR,
58
+ "Nearest": Image.Resampling.NEAREST,
59
+ }
60
+ resample = resample_map.get(resize_method, Image.Resampling.LANCZOS)
61
+ return img.resize(new_size, resample=resample)
62
+
63
+
64
+ def handle_color_profile(
65
+ img: Image.Image,
66
+ convert_to_srgb: bool,
67
+ embed_icc: bool,
68
+ rendering_intent: str,
69
+ ) -> Tuple[Image.Image, Optional[bytes]]:
70
+ """Optionally convert to sRGB and decide whether to embed ICC profile in output."""
71
+ icc_out: Optional[bytes] = None
72
+
73
+ # Map intent to PIL constant
74
+ intent_map = {
75
+ "Perceptual": ImageCms.Intent.PERCEPTUAL,
76
+ "Relative Colorimetric": ImageCms.Intent.RELATIVE_COLORIMETRIC,
77
+ "Saturation": ImageCms.Intent.SATURATION,
78
+ "Absolute Colorimetric": ImageCms.Intent.ABSOLUTE_COLORIMETRIC,
79
+ }
80
+ intent = intent_map.get(rendering_intent, ImageCms.Intent.PERCEPTUAL)
81
+
82
+ src_icc_bytes = img.info.get("icc_profile", None)
83
+
84
+ if convert_to_srgb:
85
+ try:
86
+ srgb_profile = ImageCms.createProfile("sRGB")
87
+ if src_icc_bytes:
88
+ src_profile = ImageCms.ImageCmsProfile(io.BytesIO(src_icc_bytes))
89
+ img = ImageCms.profileToProfile(img, src_profile, srgb_profile, renderingIntent=intent, outputMode="RGB")
90
+ else:
91
+ # Assume current is sRGB, ensure RGB mode
92
+ img = img.convert("RGB")
93
+ if embed_icc:
94
+ bio = io.BytesIO()
95
+ srgb_profile.tobytesio(bio)
96
+ icc_out = bio.getvalue()
97
+ except Exception:
98
+ # Fallback: at least ensure RGB mode
99
+ img = img.convert("RGB")
100
+ icc_out = src_icc_bytes if embed_icc else None
101
+ else:
102
+ # No conversion. Optionally pass-through existing ICC
103
+ if embed_icc and src_icc_bytes:
104
+ icc_out = src_icc_bytes
105
+ # Ensure compatible mode for JPEGs down the line
106
+ if img.mode not in ("RGB", "L"):
107
+ img = img.convert("RGB")
108
+
109
+ return img, icc_out
110
+
111
+
112
+ def save_as_jpeg(
113
+ img: Image.Image,
114
+ quality: int,
115
+ subsampling: str,
116
+ progressive: bool,
117
+ optimize: bool,
118
+ icc_profile: Optional[bytes],
119
+ exif_bytes: Optional[bytes],
120
+ ) -> Tuple[bytes, str]:
121
+ subsampling_map = {
122
+ "4:4:4 (no chroma downsampling)": 0,
123
+ "4:2:2": 1,
124
+ "4:2:0": 2,
125
+ "Keep (automatic)": "keep",
126
+ }
127
+ subsampling_value = subsampling_map.get(subsampling, 0)
128
+
129
+ save_params = {
130
+ "format": "JPEG",
131
+ "quality": int(quality),
132
+ "subsampling": subsampling_value,
133
+ "progressive": bool(progressive),
134
+ "optimize": bool(optimize),
135
+ }
136
+
137
+ if icc_profile:
138
+ save_params["icc_profile"] = icc_profile
139
+ if exif_bytes:
140
+ save_params["exif"] = exif_bytes
141
+
142
+ out = io.BytesIO()
143
+ img.save(out, **save_params)
144
+ return out.getvalue(), "image/jpeg"
145
+
146
+
147
+ def save_as_jpeg2000_lossless(
148
+ img: Image.Image,
149
+ icc_profile: Optional[bytes],
150
+ exif_bytes: Optional[bytes],
151
+ ) -> Tuple[Optional[bytes], Optional[str]]:
152
+ if not RUNTIME_FEATURES.get("jpeg_2000", False):
153
+ return None, None
154
+ # Pillow's JPEG 2000 supports lossless when quality_mode set, but Pillow API varies; using default lossless for RGB.
155
+ params = {"format": "JPEG2000"}
156
+ if icc_profile:
157
+ params["icc_profile"] = icc_profile
158
+ # EXIF is not standard in JP2; omit to avoid errors
159
+ out = io.BytesIO()
160
+ try:
161
+ img.save(out, **params)
162
+ return out.getvalue(), "image/jp2"
163
+ except Exception:
164
+ return None, None
165
+
166
+
167
+ def format_filesize(num_bytes: int) -> str:
168
+ for unit in ["B", "KB", "MB", "GB"]:
169
+ if num_bytes < 1024.0:
170
+ return f"{num_bytes:.1f} {unit}"
171
+ num_bytes /= 1024.0
172
+ return f"{num_bytes:.1f} TB"
173
+
174
+
175
+ def convert(
176
+ image: Image.Image,
177
+ quality: int,
178
+ subsampling: str,
179
+ progressive: bool,
180
+ optimize: bool,
181
+ convert_to_srgb: bool,
182
+ embed_icc: bool,
183
+ rendering_intent: str,
184
+ preserve_metadata: bool,
185
+ flatten_bg_color: str,
186
+ max_width: Optional[int],
187
+ max_height: Optional[int],
188
+ resize_method: str,
189
+ lossless_mode: bool,
190
+ ) -> Tuple[Optional[str], Optional[Image.Image], str]:
191
+ if image is None:
192
+ return None, None, "No image provided."
193
+
194
+ original_format = image.format or "PNG"
195
+
196
+ # Prepare metadata
197
+ exif_bytes = image.info.get("exif", None) if preserve_metadata else None
198
+
199
+ # Resize first to keep quality
200
+ image = apply_resize(image, max_width, max_height, resize_method)
201
+
202
+ # Color management
203
+ image, icc_profile = handle_color_profile(
204
+ image, convert_to_srgb=convert_to_srgb, embed_icc=embed_icc, rendering_intent=rendering_intent
205
+ )
206
+
207
+ # Flatten alpha for JPEG encoders
208
+ # Convert hex color like '#RRGGBB' to RGB tuple
209
+ bg_rgb = tuple(int(flatten_bg_color[i : i + 2], 16) for i in (1, 3, 5))
210
+ image_rgb = flatten_transparency_to_background(image, bg_rgb)
211
+
212
+ # Decide output based on lossless mode
213
+ out_bytes: Optional[bytes] = None
214
+ out_mime: Optional[str] = None
215
+ used_codec = ""
216
+
217
+ if lossless_mode:
218
+ # Prefer JPEG 2000 lossless if available
219
+ if RUNTIME_FEATURES.get("jpeg_2000", False):
220
+ out_bytes, out_mime = save_as_jpeg2000_lossless(image_rgb, icc_profile=icc_profile, exif_bytes=exif_bytes)
221
+ used_codec = "JPEG 2000 (lossless)" if out_bytes else ""
222
+ # If JP2 not available or failed, fall back to near-lossless baseline JPEG with safest settings
223
+ if out_bytes is None:
224
+ out_bytes, out_mime = save_as_jpeg(
225
+ image_rgb,
226
+ quality=100,
227
+ subsampling="4:4:4 (no chroma downsampling)",
228
+ progressive=False,
229
+ optimize=True,
230
+ icc_profile=icc_profile,
231
+ exif_bytes=exif_bytes,
232
+ )
233
+ used_codec = "Baseline JPEG (near-lossless q=100 4:4:4)"
234
+ else:
235
+ out_bytes, out_mime = save_as_jpeg(
236
+ image_rgb,
237
+ quality=quality,
238
+ subsampling=subsampling,
239
+ progressive=progressive,
240
+ optimize=optimize,
241
+ icc_profile=icc_profile,
242
+ exif_bytes=exif_bytes,
243
+ )
244
+ used_codec = "Baseline JPEG"
245
+
246
+ if out_bytes is None:
247
+ return None, None, "Encoding failed."
248
+
249
+ # Build a named output file and a small preview image
250
+ out_ext = ".jpg" if out_mime == "image/jpeg" else ".jp2"
251
+ out_name = f"converted{out_ext}"
252
+
253
+ import tempfile
254
+ out_path = os.path.join(tempfile.gettempdir(), out_name)
255
+ with open(out_path, "wb") as f:
256
+ f.write(out_bytes)
257
+
258
+ # Attempt to preview
259
+ preview_img: Optional[Image.Image] = None
260
+ try:
261
+ preview_img = Image.open(io.BytesIO(out_bytes)).copy()
262
+ except Exception:
263
+ preview_img = None
264
+
265
+ # Build report
266
+ original_size = None
267
+ try:
268
+ # Encode original as PNG bytes length if available from input file object attached by Gradio
269
+ if hasattr(image, "fp") and hasattr(image.fp, "name") and os.path.exists(image.fp.name):
270
+ original_size = os.path.getsize(image.fp.name)
271
+ except Exception:
272
+ original_size = None
273
+
274
+ final_size = len(out_bytes)
275
+ ratio_text = ""
276
+ if original_size:
277
+ ratio = final_size / original_size
278
+ ratio_text = f" | size ratio: {ratio:.2f}x"
279
+
280
+ feature_notes = []
281
+ if lossless_mode and used_codec.startswith("Baseline JPEG"):
282
+ feature_notes.append("Lossless mode fell back to near-lossless JPEG because JPEG 2000 support was unavailable.")
283
+ if not RUNTIME_FEATURES.get("jpeg_2000", False):
284
+ feature_notes.append("JPEG 2000 encoder not available in this runtime.")
285
+
286
+ info = (
287
+ f"Original: {original_format} → Output: {used_codec}\n"
288
+ f"Output size: {format_filesize(final_size)}{ratio_text}\n"
289
+ + ("\n".join(feature_notes) if feature_notes else "")
290
+ ).strip()
291
+
292
+ return out_path, preview_img, info
293
+
294
+
295
+ with gr.Blocks(title="PNG → JPEG Converter (Advanced)") as demo:
296
+ gr.Markdown("""
297
+ **PNG → JPEG Converter (Advanced)**
298
+ - Convert PNG images to JPEG with fine-grained control over quality, subsampling, progressive encoding, color profiles, and metadata.
299
+ - Lossless mode prefers JPEG 2000 (if available), otherwise falls back to a near-lossless baseline JPEG (quality 100, 4:4:4).
300
+ """)
301
+
302
+ with gr.Row():
303
+ with gr.Column(scale=1):
304
+ inp = gr.Image(type="pil", label="Input PNG")
305
+ run_btn = gr.Button("Convert", variant="primary")
306
+ with gr.Column(scale=1):
307
+ out_file = gr.File(label="Output File")
308
+ out_preview = gr.Image(type="pil", label="Preview (if supported)")
309
+ out_info = gr.Textbox(label="Details", lines=4)
310
+
311
+ with gr.Accordion("Encoding Options", open=True):
312
+ with gr.Row():
313
+ quality = gr.Slider(1, 100, value=90, step=1, label="JPEG Quality")
314
+ subsampling = gr.Dropdown(
315
+ [
316
+ "4:4:4 (no chroma downsampling)",
317
+ "4:2:2",
318
+ "4:2:0",
319
+ "Keep (automatic)",
320
+ ],
321
+ value="4:2:0",
322
+ label="Chroma Subsampling",
323
+ )
324
+ with gr.Row():
325
+ progressive = gr.Checkbox(value=True, label="Progressive JPEG")
326
+ optimize = gr.Checkbox(value=True, label="Optimize Huffman Tables")
327
+
328
+ with gr.Accordion("Color & Metadata", open=False):
329
+ with gr.Row():
330
+ convert_to_srgb = gr.Checkbox(value=True, label="Convert to sRGB")
331
+ embed_icc = gr.Checkbox(value=True, label="Embed ICC Profile in Output")
332
+ rendering_intent = gr.Dropdown(
333
+ [
334
+ "Perceptual",
335
+ "Relative Colorimetric",
336
+ "Saturation",
337
+ "Absolute Colorimetric",
338
+ ],
339
+ value="Perceptual",
340
+ label="Rendering Intent",
341
+ )
342
+ preserve_metadata = gr.Checkbox(value=True, label="Preserve EXIF/metadata when possible")
343
+
344
+ with gr.Accordion("Resize & Transparency", open=False):
345
+ with gr.Row():
346
+ max_width = gr.Number(value=None, label="Max Width (px)")
347
+ max_height = gr.Number(value=None, label="Max Height (px)")
348
+ resize_method = gr.Dropdown(
349
+ ["Lanczos (best)", "Bicubic", "Bilinear", "Nearest"],
350
+ value="Lanczos (best)",
351
+ label="Resize Filter",
352
+ )
353
+ flatten_bg_color = gr.ColorPicker(value="#FFFFFF", label="Background Color for Transparency")
354
+
355
+ with gr.Accordion("Lossless Mode", open=False):
356
+ note = "JPEG 2000 available" if RUNTIME_FEATURES.get("jpeg_2000", False) else "JPEG 2000 NOT available; will use near-lossless baseline JPEG"
357
+ lossless_mode = gr.Checkbox(value=False, label=f"Use Lossless Mode ({note})")
358
+
359
+ run_btn.click(
360
+ fn=convert,
361
+ inputs=[
362
+ inp,
363
+ quality,
364
+ subsampling,
365
+ progressive,
366
+ optimize,
367
+ convert_to_srgb,
368
+ embed_icc,
369
+ rendering_intent,
370
+ preserve_metadata,
371
+ flatten_bg_color,
372
+ max_width,
373
+ max_height,
374
+ resize_method,
375
+ lossless_mode,
376
+ ],
377
+ outputs=[out_file, out_preview, out_info],
378
+ )
379
+
380
+ if __name__ == "__main__":
381
+ demo.launch