libvips / pyvips

python binding for libvips using cffi
MIT License
644 stars 49 forks source link

Struggling to keep the tone depth of an rgb tiff image using tiffsave #421

Open MAJAQA opened 1 year ago

MAJAQA commented 1 year ago

I having trouble to keep the tone depth of an rgb tiff image. What am I missing? The output tiff file appears darker over the whole tone range...

i = py.Image.new_from_file(image_r, access="sequential")
cr = i[0]
crl = cr.tolist()
n = np.array(crl)
ni = py.Image.new_from_array(n, interpretation='srgb')
ni.tiffsave(image_w, compression='lzw', xres=i.xres, yres=i.yres)

Input and output in this zip file: example.zip

jcupitt commented 1 year ago

Hi @MAJAQA,

Your numpy array is int64, so libvips is saving it as an int32 TIFF image (the closest it can get).

You can see what's happening like this:

john@banana ~/try $ python
Python 3.11.4 (main, Jun  9 2023, 07:59:55) [GCC 12.3.0] on linux
Type "help", "copyright", "credits" or "license" for more information.
>>> import pyvips
>>> import numpy as np
>>> image = pyvips.Image.new_from_file("image_r.tif")
>>> cr = image[0]
>>> crl = cr.tolist()
>>> n = np.array(crl)
>>> n.dtype
dtype('int64')
>>> 

numpy is just seeing a python array of integers, so it's wrapping it up as an array of int64.

A better way to go to numpy is like this:

>>> image = pyvips.Image.new_from_file("image_r.tif")
>>> cr = image[0]
>>> n = cr.numpy()
>>> n.dtype
dtype('uint8')
>>> 

The numpy() method on a pyvips image will make a numpy array directly from the libvips pixels, so there's no copy, it's much faster, and the numeric type is preserved. Now if you save, you'll get an 8-bit TIFF.

You could also improve this:

cr = i[0]

I guess you want to convert to mono? You'll find:

cr = i.colourspace("b-w")

Will usually give better results.

MAJAQA commented 1 year ago

I used the single channel approach as I want to process the tones as integer values (0 to 255), so not mono, but gray levels. Tried: n = np.array(crl, dtype=np.int8) then the output is 'identical' to the input, however I get deprecation warnings for all values > 128: ex. C:\Users\maja\OneDrive - Veralto\TechTime\snowme\snowme_github_issue-posted.py:62: DeprecationWarning: NumPy will stop allowing conversion of out-of-bound Python integers to integer arrays. The conversion of 129 to int8 will fail in the future. For the old behavior, usually: np.array(value).astype(dtype) will give the desired result (the cast overflows). n = np.array(crl, dtype=np.int8)

MAJAQA commented 1 year ago

needed to use 'uint8' then ok

jcupitt commented 1 year ago

I used the single channel approach as I want to process the tones as integer values (0 to 255), so not mono, but gray levels.

.colourspace("b-w") will make a mono image. It's just a better version of what you have.

n = np.array(crl, dtype=np.int8)

I wouldn't do this, it'll be very slow and eat huge amounts of memory. Just use n = cr.numpy().

MAJAQA commented 1 year ago

I 'can't' use the cr.numpy(), because that's the original image. I am converting the cr to the crl, which is the 'list' version of the image, so I can 'easily' search and change the image values. The whole idea behind this test is to try to 'simulate' how an image would look like after ripping (using certain screening technology) and printing it on a traditional flexo printing press.... Had to leave out my '#' code comments as that seems to break the 'code' inserting tool: ex. `code line 1

'# preceded' comment line2

code line3 ` How can you prevent this? >> then I can add my part of test code to show what I am trying to do... Always open for suggestions...

The images I am using are 'small' so no real memory issues at the moment. Processing it is done so fast, I can't even see the amount of memory python takes in the windows task manager... Also, the final 'simulation' image needs to be shown on screen, so that's pretty 'low' resolution too, so I can scale the images if needed when memory use would become a problem.

btw. The images I use are single color (Black) 'representing' 1 plate (ink) of the printing press, so when I saw "b-w" I assumed it would be black and white (binary) only, but it indeed keeps all tone levels 0 to 255 and is a better approach if you would use full color images.

jcupitt commented 1 year ago

You write code in markdown, so three backticks for a many-line code block. You can put the name of the language to get syntax highlighting that exact thing, eg:

    ```python
    a = b + 12    

Renders as:

```python
a = b + 12

You can use a single backtick for inline code:

    this is `some python` inline

Appears as:

this is some python inline

The markdown docs are worth a look.

https://github.com/adam-p/markdown-here/wiki/Markdown-Cheatsheet

MAJAQA commented 1 year ago

The CheatSheet is very helpful indeed!

here is my embryo code ... left out the kDotSpiral spec since that's company confidential ...

#kMinDots4000 = [6, 9, 12, 16, 19, 22, 25, 31, 36, 43, 48, 54, 60]
kMinDots4000 = [15, 30, 60]

def SimulatePrintedFM (image_r, toneFM, mindotList):

    #load the image into a n-channel stream
    #i = py.Image.new_from_file(image_r, access="sequential")
    i = py.Image.new_from_file(image_r)
    imY = i.height
    imW = i.width
    print('image - bands={0} format={1} interpretation={2} coding={3}'.format(i.bands, i.format, i.interpretation, i.coding))
    print(i)

    #get the tones from the 1st image channel(=r from rgb)
    #cr = i[0]
    cr = i.colourspace("b-w")
    #convert the pixel tones to a list of coordinates and tone values
    crl = cr.tolist()
    nr_rows = len(crl)
    nr_cols = len(crl[0])
    #print('nr_rows={0} nr_cols={1}'.format(nr_rows,nr_cols))
    #print(crl)

    # coord_list is the list of coordinates per tone in the highlights 
    coord_list = []
    for s in range(toneFM,255):
        clist = []
        for y in range(nr_rows):
            for x in range(nr_cols):
                if crl[y][x] == s:
                    lCoordinates = [y,x]
                    clist.append(lCoordinates)
        #print(clist)
        coord_list.append(clist)

    # for all mindots, make a print simulation version
    for mDot in mindotList:
        # iterate for all tones
        tmp_crl = crl
        tmp_coord_list = coord_list
        MinDot_4000 = mDot
        kMinDotResolutionPpmm = 4000/25.4
        MinDot_scaled = round(MinDot_4000/(kMinDotResolutionPpmm/i.xres))
        # need to round to make sure to at least show 1 pixel in output
        print('image - res_ppmm={0} nr_pix_y={1} nr_pix_x={2} Mindot_scaled={3}'.format(i.xres,imY,imW,MinDot_scaled))
        grainyTone=toneFM
        ToneSeadStep=5/(255-toneFM)
        ToneSeadFrequency=5*MinDot_scaled

        for t in range(len(tmp_coord_list)):
            step = 255-toneFM-t
            pixs = len(tmp_coord_list[t])
            #print(hlist[t])
            print('steps={0} pixs={1}'.format(step,pixs))
            s = step
            # enable start of grainy dots at very beginning
            c = random.randint(0,ToneSeadFrequency)
            # gradually make the grainy dots darker
            grainyTone -= ToneSeadStep
            while s < pixs:
                #print('hlist[{0}]={1}'.format(s,hlist[t][s]))
                if c == ToneSeadFrequency:
                    c = 0
                    tone = int(grainyTone)
                else:
                    tone = 255
                # every 'step' pixels, make a white pixel, but every 'ToneSeadFrequency' add a bit darker one
                # this should give the impression the mindot becomes visible at the very lowest tones >> visualises the printed 'graininess'
                # when using subsampled image (72 dpi only) >> crl[hlist[t][s][0]][hlist[t][s][1]] = tone
                # now adding real size mindot (scaled to image resolution)
                for pix in range(MinDot_scaled):
                    x,y = tmp_coord_list[t][s][0] + kDotSpiral[pix][0], tmp_coord_list[t][s][1] +kDotSpiral[pix][1]
                    #print('pix={0} pos-x={1} pos-y={2}'.format(pix, x, y))
                    tmp_crl[x][y] = tone
                # add random seed to eleminate patterns(ex. in linear wedges, fadings, ...) 
                s += int(random.randint(1,30)/10)
                s += step
                c += 1
        #print(crl)       

        # convert the list to a numpy array to enable tiffsave to grayscale
        n = np.array(tmp_crl, dtype=np.uint8)
        ni = py.Image.new_from_array(n, interpretation='rgb')
        #ni = py.Image.new_from_list(n)
        image_w = os.path.splitext(image_r)[0] + '-snow-FM' + str(toneFM) + '-mindot' + str(mDot) + '.tif'
        ni.tiffsave(image_w, compression='lzw', xres=i.xres, yres=i.yres)
jcupitt commented 1 year ago

Ah gotcha. I'd use numpy arrays rather than list(), but I can see why you went that way.

MAJAQA commented 1 year ago

@jcupitt, As a 'hobby' programmer, I consider this a compliment. Thanks for the tips, hope it's useful for other programmers too.