Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support reading from stdin, writing to stdout in HEIC2PNG #5

Open
wants to merge 11 commits into
base: main
Choose a base branch
from
4 changes: 2 additions & 2 deletions src/heic2png/__init__.py
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
__version__ = "1.1.4"

from .heic2png import *
from .cli import main
#from .heic2png import *
#from .cli import main
70 changes: 41 additions & 29 deletions src/heic2png/cli.py
Original file line number Diff line number Diff line change
@@ -1,71 +1,83 @@
import sys
import traceback
import argparse
from pillow_heif import register_heif_opener

from . import __version__
from .heic2png import HEIC2PNG
from heic2png import __version__
from heic2png.heic2png import HEIC2PNG


def eprint(*args, file=sys.stderr, **kwds):
"print to stderr by default"
print(*args, file=file, **kwds)

def cli(args):
"""
Command Line Interface for converting HEIC images to PNG.

:param args: Parsed command-line arguments.
"""
print(f'Processing the HEIC image at `{args.input_path}`')
eprint(f'Processing the HEIC image at `{args.input_path}`')

if args.output_path:
print(f'Specified output path: `{args.output_path}`')
eprint(f'Specified output path: `{args.output_path}`')

if args.quality and not 1 <= args.quality <= 100:
print('Error: Quality should be a value between 1 and 100.')
eprint('Error: Quality should be a value between 1 and 100.')
return

try:
print('==========================')
print('==== HEIC2PNG Options ====')
print('==========================')
print(f'>> Input file path: {args.input_path}')
print(f'>> Output file path: {args.output_path}')
print(f'>> Quality: {args.quality}')
print(f'>> Overwrite: {args.overwrite}')
print('==========================')
eprint('==========================')
eprint('==== HEIC2PNG Options ====')
eprint('==========================')
eprint(f'>> Input file path: {args.input_path}')
eprint(f'>> Output file path: {args.output_path}')
eprint(f'>> Quality: {args.quality}')
eprint(f'>> Overwrite: {args.overwrite}')
eprint('==========================')
heic_img = HEIC2PNG(args.input_path, args.quality, args.overwrite)
print('Converting the image...')
eprint('Converting the image...')

if args.output_path and args.overwrite:
print(f'Overwriting the existing file at `{args.output_path}`')
eprint(f'Overwriting the existing file at `{args.output_path}`')

output_path = heic_img.save(args.output_path)
print(f'Success! The converted image is saved at `{output_path}`')
eprint(f'Success! The converted image is saved at `{output_path}`')

except FileExistsError:
print('Error: The specified output file already exists.')
print('Use the -w option to overwrite the existing file.')
eprint('Error: The specified output file already exists.')
eprint('Use the -w option to overwrite the existing file.')

except ValueError as e:
print('Error: Invalid input or output format.')
print(e)
eprint('Error: Invalid input or output format.')
eprint(e)
traceback.print_exc()

except Exception as e:
print(f'An unexpected error occurred: {e}')
print('Here are the details:')
print('==========================')
eprint(f'An unexpected error occurred: {e}')
eprint('Here are the details:')
eprint('==========================')
traceback.print_exc()
print('==========================')
print('Please report this issue with the full traceback.')
print('-> https://github.com/NatLee/HEIC2PNG/issues')
eprint('==========================')
eprint('Please report this issue with the full traceback.')
eprint('-> https://github.com/NatLee/HEIC2PNG/issues')



def main():
"""
Main function to register the HEIF opener and initiate the argparse CLI.
"""
register_heif_opener()

print(f'HEIC2PNG v{__version__}')
eprint(f'HEIC2PNG v{__version__}')

parser = argparse.ArgumentParser(description="Convert HEIC images to PNG.")
parser.add_argument("-i", "--input_path", required=True, help="Path to the input HEIC image.")
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you clarify the rationale behind removing the required flag?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the input comes from sys.stdin, there's not going to be an explicit input file, so requiring one doesn't make a ton of sense.

I'm not going to hold it up as best practice, but I have another package on GitHub, csvprogs which goes the other way. I had always supported using stdin and stdout for IO, only supporting filled in a hit-or-miss fashion. It has grown organically though, so different tools were organized differently. In a fit of virtual housecleaning, I tackled this inconsistency. I was able to come up with a relatively clean way to support both styles of consuming input and generating output (see the openio function and its usage).

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Understood. I believe it is necessary to add a check within the CLI function to verify whether input_path is None. Without this validation, the program might fail to locate the file and become unresponsive.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That's how Unix-style filters work. This isn't quite how heic2png is organized, but many Unix/Linux commands will read from input files or stdin. Effectively, you have a CLI which looks like this:

heic2png [ options ] [ infile [ outfile ] ]

Both the input file and output file are optional (though if you want to specify an output file you must also specify an input file, hence the nesting brackets). Ignoring the fact that your current CLI explicitly calls for -i and/or -o, I could run a more traditional Unix command like this:

heic2png ... ~/misc/myimage.heic ~/misc/myimage.png

or

heic2png ... ~/misc/myimage.heic | pngtopnm | pnmtoxwd > ~/misc/myimage.xwd

or even

heic2png ... < ~/misc/myimage.heic | pngtopnm | pnmtoxwd > ~/misc/myimage.xwd

If you look at the openio function in my csvprogs package, you'll see that's what it sorts out. If the input file has a fileno attribute, it assumes it's an open file, like sys.stdin:

>>> sys.stdin.fileno
<built-in method fileno of _io.TextIOWrapper object at 0x1008e5700>

The same goes for the output file. If not, it assumes it's a file which needs to be opened.

Clearly, if the user runs

heic2png

without any files or I/O redirection, it will appear to hang. It's not really hung though, just waiting for input from stdin. Sending in a KeyboardInterrupt solves that problem, after which hopefully the user will think to run it with the -h option. (I will tweak the help messages to make it clear what happens in the absence of the -i or -o options.)

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you very much for your explanation. For this revision, I have only tested it in a non-Unix environment like Windows. I will test it later on the Mac.

parser = argparse.ArgumentParser(description="Convert HEIC images to PNG.",
epilog="""
In the absence of an input (-i) file, input is read from sys.stdin.
In the absence of an output (-o) file, output is written to sys.stdout.
""")
parser.add_argument("-i", "--input_path", help="Path to the input HEIC image.")
parser.add_argument("-o", "--output_path", help="Path to save the converted PNG image.")
parser.add_argument("-q", "--quality", type=int, help="Quality of the converted PNG image (1-100).")
parser.add_argument("-w", "--overwrite", action="store_true", help="Overwrite the existing file if it already exists.")
Expand Down
53 changes: 34 additions & 19 deletions src/heic2png/heic2png.py
Original file line number Diff line number Diff line change
@@ -1,52 +1,67 @@
import os
import sys
import subprocess
from pathlib import Path
from typing import Optional

from PIL import Image
from pillow_heif import register_heif_opener

register_heif_opener()

class HEIC2PNG:
def __init__(self, image_file_path: str, quality: Optional[int] = None, overwrite: bool = False):
def __init__(self, image_file_path: Optional[str] = "",
quality: Optional[int] = None,
overwrite: bool = False):
"""
Initializes the HEIC2PNG converter.

:param image_file_path: Path to the HEIC image file.
:param quality: Quality of the converted PNG image (1-100).
:param overwrite: Whether to overwrite the file if it already exists.
"""
self.image_file_path: Path = Path(image_file_path)
if not image_file_path:
stdin = (os.fdopen(sys.stdin.fileno(), "rb")
if "b" not in sys.stdin.mode else sys.stdin)
self.image_file_path = stdin
# TODO: I'm not a Python type hints maven. The image_file_path type
# needs to reflect that an open file object is also acceptable.
elif hasattr("fileno", image_file_path):
pass # already open file
else:
self.image_file_path: Path = Path(image_file_path)
if self.image_file_path.suffix.lower() != '.heic':
raise ValueError("The provided file is not a HEIC image.")
self.quality: Optional[int] = quality
self.overwrite: bool = overwrite

if not self.image_file_path.is_file():
raise FileNotFoundError(f"The file {image_file_path} does not exist.")

if self.image_file_path.suffix.lower() != '.heic':
raise ValueError("The provided file is not a HEIC image.")

self.image: Image.Image = Image.open(self.image_file_path)

def save(self, output_image_file_path: Optional[str] = None, extension: str = '.png') -> Path:
def save(self, output_image_file_path: Optional[str] = "",
extension: str = '.png') -> Path:
"""
Converts and saves the HEIC image to PNG format.
Converts and saves the input image to another format.

The default output format is PNG.

:param output_image_file_path: Path to save the converted PNG image.
:param extension: The file extension of the converted image.
:return: Path where the converted image is saved.
"""
if output_image_file_path:
output_path: Path = Path(output_image_file_path)
if output_path.suffix.lower() != extension:
raise ValueError("The output file extension does not match the specified extension.")
if not output_image_file_path:
stdout = (os.fdopen(sys.stdout.fileno(), "wb")
if "b" not in sys.stdout.mode else sys.stdout)
output_path = stdout
else:
output_path: Path = self.image_file_path.with_suffix(extension)

if not self.overwrite and output_path.exists():
raise FileExistsError(f"The file {output_path} already exists.")
output_path: Path = Path(output_image_file_path)

self.image.save(output_path)
# TODO: Not sure this is correct, there's probably a better mapping
# between image file extensions and file formats. For example, see the
# output of `python -m PIL`.
self.image.save(output_path, format=extension.replace(".", "").upper())
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We could refer to this link to retrieve a list of supported formats with PIL: https://stackoverflow.com/a/71114152

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I tested it in Python 3.12, but I'm unsure if this function works across all versions of PIL.

>>> from PIL import Image
>>> exts = Image.registered_extensions()
>>> supported_extensions = {ex for ex, f in exts.items() if f in Image.OPEN}
>>> supported_extensions
{'.wmf', '.icns', '.gif', '.grib', '.pnm', '.ftu', '.jpe', '.rgb', '.dib', '.tif', '.jfif', '.emf', '.jpc', '.pxr', '.ppm', '.dds', '.fit', '.icb', '.cur', '.pcx', '.bmp', '.h5', '.pfm', '.qoi', '.dcx', '.apng', '.iim', '.pbm', '.png', '.jpx', '.bw', '.psd', '.pcd', '.sgi', '.vda', '.im', '.mpeg', '.bufr', '.mpg', '.rgba', '.xpm', '.fli', '.fits', '.ps', '.flc', '.gbr', '.msp', '.j2c', '.jpg', '.tga', '.ico', '.xbm', '.pgm', '.j2k', '.jpf', '.jpeg', '.vst', '.hdf', '.ras', '.tiff', '.ftc', '.jp2', '.eps', '.webp', '.blp'}

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would be nice if there was a mapping from file extensions to formats. I think ultimately, that's what really matters.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe we can select a few from within and create a map like this:

class HEIC2PNG:
  FORMAT_MAPPING: Dict[str, str] = {
      '.png': 'PNG',
      '.jpg': 'JPEG',
      '.jpeg': 'JPEG',
      '.webp': 'WEBP',
      '.bmp': 'BMP',
      '.gif': 'GIF',
      '.tiff': 'TIFF',
  }
  def __init__(...):
    ...

We can make adjustments at this point.

self.image.save(output_path, format=extension.replace(".", "").upper())

This line can be adjusted using the following approach: the variable image_format is assigned based on the file extension mapping (self.FORMAT_MAPPING[ext]), and the image is subsequently saved to the specified output_path in the determined format.

The snippet appears as follows:

...
ext = extension.lower()
if ext not in self.FORMAT_MAPPING:
    supported_formats = ', '.join(self.FORMAT_MAPPING.keys())
    raise ValueError(f"Unsupported format: {extension}. Supported formats are: {supported_formats}")

image_format = self.FORMAT_MAPPING[ext]
self.image.save(output_path, format=image_format)
...

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is just a method I came up with on short notice. It may not be the best approach, but feel free to use it as a reference.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Just thinking out loud here, but Python has a standard mimetypes module. The IANA has an official MIME type list as well. Maybe we should see if we can leverage these existing tools.

>>> import mimetypes
>>> mimetypes.guess_type("myfile.heic")
('image/heic', None)

Getting from image/heic to whatever PIL wants (assuming it barfs on the official MIME type name) shouldn't be difficult.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

PIL.Image has a MIME dictionary which the various image plugins are supposed to populate. For example:

>>> from PIL import Image
>>> from pillow_heif import register_heif_opener
>>> 
>>> register_heif_opener()
>>> Image.MIME["HEIF"]
'image/heif'

Unfortunately the HEIF opener doesn't register image/heic. The IANA description is a bit confusing to me.

Still, this avenue might be worth pursuing.


# TODO: Skip this step if the output format isn't PNG or pngquant can't
# be found?
Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Does the other format specify parameters like quality or compression ratio? If not, we can skip this check.

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Some formats (like JPEG) support image quality. Generally speaking, this is only applicable to lossy formats. I wasn't aware that PNG could be lossy.

Copy link
Owner

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Apologies if my wording wasn’t clear. PNG is a lossless format.
However, when we convert HEIC to PNG, we use tools like pngquant.
In this case, the quality I’m referring to is the parameter for this tool, not quality levels like in JPEG.
Tools such as pngquant utilize color reduction and lossy conversion techniques, which may compromise image quality—somewhat contradicting PNG's renowned lossless nature.

# Optimize PNG with pngquant if quality is specified
if self.quality and self.quality != 100:
quality_str: str = f'{self.quality}-{self.quality}'
Expand Down
Binary file added tests/calder-flamingo.heic
Binary file not shown.
26 changes: 24 additions & 2 deletions tests/test_heic2png.py
Original file line number Diff line number Diff line change
@@ -1,9 +1,13 @@
import pytest
import numpy as np
import os
import sys
import subprocess
from pathlib import Path

import pytest
import numpy as np
from PIL import Image
from pillow_heif import register_heif_opener

from heic2png.heic2png import HEIC2PNG

# Paths for test files
Expand Down Expand Up @@ -46,3 +50,21 @@ def test_quality(cleanup):
high_quality_size = Path(TEST_HIGH_QUALITY_OUTPUT_FILENAME).stat().st_size

assert low_quality_size < high_quality_size, "High quality image should be larger in size"

def test_stdin_stdout():
inputfile = "tests/calder-flamingo.heic"
outputfile = "tests/calder-flamingo.png"
try:
save_stdin, save_stdout = (sys.stdin, sys.stdout)
if os.path.exists(outputfile):
os.unlink(outputfile)
with (open(inputfile, "rb") as sys.stdin,
open(outputfile, "wb") as sys.stdout):
converter = HEIC2PNG()
converter.save()
finally:
sys.stdin, sys.stdout = save_stdin, save_stdout
with (open(inputfile, "rb") as inp,
open(outputfile, "rb") as outp):
assert len(outp.read()) > len(inp.read())
os.unlink(outputfile)
2 changes: 1 addition & 1 deletion tox.ini
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
[tox]
envlist = py{37,38,39}
envlist = py{37,38,39,310,311,312,313}
minversion = 3.3.0
isolated_build = true

Expand Down