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

Lossless encoding/decoding of 10bit grayscale image stored as 16 bit pngs #562

Closed
tvercaut opened this issue Nov 8, 2021 · 5 comments
Closed

Comments

@tvercaut
Copy link

tvercaut commented Nov 8, 2021

Hello,

Apologies if this is not the right place to ask questions but I couldn't find a forum or a Discussions tab.

I am trying to encode 10 bit grayscale images losslessly in heic. The images are stored as 16 bit png files (but only use the lest significant 10 bits).

If I simply use

heif-enc -b 10 -L -v -o output-10bit-lsb.heic input-10bit-lsb.png

the resulting heic image looks completely dark (using Preview.app on macos 12.0.1) so I guess heif-enc does not only use the 10 least significant bits. If I scale / bitshift or left replicate the original intensities in the input png, the generated heic file looks ok on preview.app but I can't get back the original values.

If I simply use

heif-convert output-10bit-msb.heic recons-10bit-msb.png

the resulting png is an 8 bit rgb file. Using imagemagick seems to help there as I can generate 16bit images with

magick convert output-10bit-msb.heic -depth 10 recons-10bit-msb.png

However, the result is not lossless. Is there a way to do such a lossless round-trip with libheif command-line tools?

To ease replicating my issue, below if a simple python script that generates the 10bit image on the fly:

import subprocess
import numpy as np
import tempfile
import imageio

# Create simple image with  gradient from
# 0 to (2^bitdepth - 1)
bitdepth = 10
unusedbitdepth = 16-bitdepth
hbd = int(bitdepth/2)
im0 = np.zeros((1<<hbd,1<<hbd),dtype=np.uint16)
im0[:] = np.arange(0,1<<bitdepth).reshape(im0.shape)

# Tile it to be at least 64 pix as x265 encoder may only work
# with image of size 64 and up
im0 = np.tile(im0, (2, 2))
print('im0',np.min(im0),np.max(im0),im0.shape,im0.dtype)

# bitshift it or rescale intensities
im0ref = im0
im0 = (im0<<6) # Bitshift the values to use most significant bits
#im0 = (im0<<6) + (im0>>4)  Left bit replication as a cost-effective approximation of scaling (See http://www.libpng.org/pub/png/spec/1.1/PNG-Encoders.html)
#im0 = np.uint16(np.round(im0 * np.float64((1<<16)-1)/np.float64((1<<10)-1))) # Scale the values use all 16 bits
print('im0',np.min(im0),np.max(im0),im0.shape,im0.dtype)

# Save it
tmp0 = tempfile.NamedTemporaryFile(suffix='.png', delete=False)
print(f'Using tmp file: {tmp0.name}')
imageio.imwrite(tmp0.name,im0)

# Encode with heif-enc
tmp1 = tempfile.NamedTemporaryFile(suffix='.heic', delete=False)
mycmd = f'heif-enc -b 10 -L -v -o {tmp1.name} {tmp0.name}'
print(mycmd)
p = subprocess.run(mycmd.split(), capture_output=True)
print( 'stdout:', p.stdout.decode() )
print( 'stderr:', p.stderr.decode() )
if p.returncode:
    exit(p.returncode)

# Decode with heif-convert or imagemagick
tmp2 = tempfile.NamedTemporaryFile(suffix='.png', delete=False)
mycmd = f'magick convert {tmp1.name} -depth 10 {tmp2.name}'
#mycmd = f'heif-convert {tmp1.name} {tmp2.name}'
print(mycmd)
p = subprocess.run(mycmd.split(), capture_output=True)
print( 'stdout:', p.stdout.decode() )
print( 'stderr:', p.stderr.decode() )
if p.returncode:
    exit(p.returncode)

# Read back
im1 = imageio.imread(tmp2.name)
print('im1',np.min(im1),np.max(im1),im1.shape,im1.dtype)

# Bitshift or scale back
im1pre = im1
im1 = (im1>>6)
#im1 = np.uint16(np.round(im1 * np.float64((1<<10)-1)/np.float64((1<<16)-1)))

print('err: ',np.linalg.norm((np.float32(im1)-np.float32(im0ref)).ravel()))

Note that my question is very similar to that I have about doing this with ffmpeg:
https://stackoverflow.com/q/69739665/17261462
In the case of ffmpeg, achieving a lossless roundrip with HEVC is at least possible by using temporary rawvideo file:
https://stackoverflow.com/a/69874453/17261462

For ease of use, I am also attaching some sample pngs generated from teh python commands above.

LSB version:
gradient10bit-lsb

MSB version:
gradient10bit-msb

LBR version:
gradient10bit-leftbitreplication

@dr-eme
Copy link

dr-eme commented Apr 14, 2023

Hi,

The lossless round trip is possible, but requires bit shifting both before encoding AND after decoding.

Below a Python POC, which makes use of pillow_heif:

import numpy as np
import pillow_heif

# Create HEIF file from image in NDArray (dtype=uint16), and shift bits to MSB
out_file = pillow_heif.from_bytes(
    mode="I;16",
    size=(image.shape[1], image.shape[0]),
    data=np.left_shift(image, 4 if pillow_heif.options.SAVE_HDR_TO_12_BIT else 6).tobytes()
)

# Lossless econding
out_file.save("image.heif", format="HEIF", quality=-1)

# Lossless decoding
in_file = pillow_heif.open_heif("image.heif", convert_hdr_to_8bit=False)

# Shift back to LSB
decoded_image = np.right_shift(
    np.asarray(in_file, dtype=np.uint16)[:, :, 0],  # Even if monochrome, pillow_heif returns a 3-channel image
    4 if pillow_heif.options.SAVE_HDR_TO_12_BIT else 6
)

# Verify input and decoded arrays are equal
is_lossless = np.array_equal(image, decoded_image)

@bigcat88
Copy link
Contributor

@dr-eme just curious - is this code for 0.10.x versions?

and in which format image raw data is(on step from_bytes before shifting)? 10(12) bit as I understand from code, right?
with what code you get those data, in 10/12 bit?

@dr-eme
Copy link

dr-eme commented Apr 14, 2023

Yes, I used pillow-heif 0.10.1 for this.

The image is just a Numpy NDArray with dtype=uint16, there you could have either a 10-bit or 12-bit depth image in the least significant bits, like im0ref in tvercaut's example.

@bigcat88
Copy link
Contributor

The lossless round trip is possible, but requires bit shifting both before encoding AND after decoding.

Last version(0.12.0) of pillow-heif supports input/output of 10/12 bit image data.
Has returned this ability, didn't know that someone will work not in 8/16 bit.

@bigcat88
Copy link
Contributor

this probably can be closed, as provided examples even they are on python - works.

@farindk farindk closed this as completed Jul 20, 2023
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

No branches or pull requests

4 participants