Spaces:
Runtime error
Runtime error
Create app.py
Browse files
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
|