4
\$\begingroup\$

The program idea is take normal PNG with colour type 2 (Truecolour), decode it to grayscale and perform edge detection operator.

I have rewritten code asked here before.

Now my mainly concerns are:

  • Style
  • Reasoning errors (algorithms, "Why did you put it there", etc.)
  • Is it ok to have CAPS attributes e.g. IDAT or should I convert a string to lowercase?
  • Is code easy to read/understand?
  • It's unclear to me that I should use classes at all with Python
  • Is there a better way to import classes?

    from PNG import PNG
    

Image.py

from PNG import PNG
from DifferenceOperator import Prewitt, Sobel
import helpers

class Image:
    def __init__(self, filename):
        helpers.check_file(filename)
        self.image = PNG(filename)
        self.width = self.image.width
        self.height = self.image.height
        self.pixels = self.image.pixels

    def edge_detect(self, operator):
        return [operator.gradient_direction(kernel) for kernel \
                in self.get_kernels(list(self.pixels))]

    def draw_edges(self, operator):
        new_pixels = [operator.gradient_magnitude(kernel) for kernel \
                      in self.get_kernels(list(self.pixels))]
        self.pixels = helpers.parse_to_rows(new_pixels, self.width)

    def to_gray_scale(self):
        if(self.image.color_type != 0):
            self.image.color_type = 0
            self.pixels = self.get_rgb(self.pixels)

    def save(self, filename=None):
        self.image.width = self.width
        self.image.height = self.height
        self.image.pixels = self.pixels
        self.image.write(filename)

    def get_rgb(self, pixels):
        for row in pixels:
            new_row = []
            for x in range(0, self.image.width * 3, 3):
                new_row.append(int(sum(row[x:x + 3]) / 3))
            yield new_row

    def get_kernels(self, pixels, radius = 1):
        for y in range(self.height):
            y_min = y - radius
            y_max = y + radius + 1
            for x in range(self.width):
                x_min = x - radius
                x_max = x + radius + 1
                yield [
                    pixel for row in pixels[y_min:y_max]
                    for pixel in row[x_min:x_max]]


img = Image('input.png')
img.to_gray_scale()
img.draw_edges(Prewitt())
img.save("output.png")

PNG.py

import struct
import zlib
import binascii

import helpers


class PNG:

    UNFILTER_METHOD = [
        lambda *nil: 0,
        helpers.sanitize_along_x,
        helpers.sanitize_along_y,
        lambda x, y, step, data: (
                                helpers.sanitize_along_x(x, y, step, data)
                                + helpers.sanitize_along_y(x, y, step, data))
                                // 2,
        helpers.paeth,
    ]

    def __init__(self, filename):
        self.name = filename
        with open(filename, 'rb') as f:
            self.head = f.read(8) #Read PNG signature
            while True:
                length = f.read(4) #Read chunk length
                if length == b'': # Reached to end of file
                    break
                chunk_type = f.read(4).decode() #Reading chunk type and decode it
                chunk_data = f.read(int.from_bytes(length, byteorder='big')) #Read (length) bytes to chunk_data
                try:
                    if getattr(self, chunk_type): #Assuming multiple IDAT-chunks
                        setattr(self, chunk_type, getattr(self, chunk_type) + chunk_data) #Appeand data to IDAT
                    else:
                        setattr(self, chunk_type, chunk_data) #Set data
                except AttributeError:
                    print(chunk_type)
                    if chunk_type is ("PLTE" or "tRNS"): #Critical chunks to PNG file
                        raise NotImplementedError("Not implemented yet")
                    print("Not implemented yet") #Comments, times etc not important to PNG so we can move
                f.read(4) #Read crc

    @property
    def IHDR(self):
        try:
            return struct.pack('>2I5b', self.width, self.height, self.bit_depth, \
            self.color_type, self.compression_method, self.filter_method, \
            self.interlace_method)
        except AttributeError:
            return None

    @IHDR.setter
    def IHDR(self, data):
        (self.width, self.height, self.bit_depth, self.color_type,
         self.compression_method, self.filter_method, self.interlace_method
        ) = struct.unpack('>2I5b', data)


    @property
    def IDAT(self):
        try:
            return self._idat
        except AttributeError:
            return None

    @IDAT.setter
    def IDAT(self, data):
        self._idat = data

    @property
    def IEND(self):
        try:
            return self._iend
        except AttributeError:
            return None

    @IEND.setter
    def IEND(self, data):
        self._iend = data

    @property
    def pixels(self):
        decoded = helpers.parse_to_rows(zlib.decompress(self.IDAT),
                                        self.width * (self.color_type + 1) + 1)
        unfiltered = []
        for y, row in enumerate(decoded):
            filter_method, *data = row
            current_row = []
            unfiltered.append(current_row)
            for x, pixel in enumerate(data):
                px = self.UNFILTER_METHOD[filter_method] \
                (x, y, self.color_type + 1, unfiltered)
                current_row.append((px + pixel) % 256)
        return unfiltered

    @pixels.setter
    def pixels(self, new_image):
        new_idat = b''
        for row in new_image:
            new_idat += int(0).to_bytes(1, byteorder='big')
            new_idat += bytes(row)
        self.IDAT = zlib.compress(new_idat)

    def write(self, filename=None):
        if filename is None:
            filename = self.name
        with open(filename, 'wb') as f:
            f.write(self.head) #Write PNG signature
            self.write_chunk(f, b'IHDR', self.IHDR) #Write image headers
            self.write_chunk(f, b'IDAT', self.IDAT) #Write image data
            self.write_chunk(f, b'IEND', self.IEND) #Write image end

    def write_chunk(self, file_handle, chunk_name, chunk_data):
        file_handle.write(len(chunk_data).to_bytes(4, byteorder='big'))
        file_handle.write(chunk_name)
        file_handle.write(chunk_data)
        file_handle.write(binascii.crc32 \
                        (chunk_name + chunk_data).to_bytes(4, byteorder='big'))

DifferenceOperator.py

import math

class DifferenceOperator:

    def gradient_magnitude(self, kernel):
        if len(kernel) < 9:
            return 0

        gx, gy = self.derivative_approx(kernel)
        return int((gx**2 + gy**2) ** (1/2)) % 256

    def gradient_direction(self, kernel):
        if len(kernel) < 9:
            return None
        gx, gy = self.derivative_approx(kernel)
        if gx or gy:
            return math.degrees(math.atan2(gy, gx))

    def derivative_approx(self, kernel):
        gx = sum(i*j for i, j in zip(kernel, self.MASK_X))
        gy = sum(i*j for i, j in zip(kernel, self.MASK_Y))
        return gx, gy


class Prewitt(DifferenceOperator):
    MASK_X = [-1, 0, 1, -1, 0, 1, -1, 0, 1]
    MASK_Y = [-1, -1, -1, 0, 0, 0, 1, 1, 1]

class Sobel(DifferenceOperator):
    MASK_X = [-1, 0, 1, -2, 0, 2, -1, 0, 1]
    MASK_Y = [-1, -2, -1, 0, 0, 0, 1, 2, 1]

    def gradient_direction(self, kernel):
        gx, gy = self.derivative_approx(kernel)
        return math.atan(gy / gx)

helpers.py

def sanitize_along_x(x, y, step, data):
    return 0 if x < step else data[y][x - step]

def sanitize_along_y(x, y, step, data):
    return 0 if not y else data[y - 1][x]

def paeth(x, y, step, data):
    a = sanitize_along_x(x, y, step, data)
    b = sanitize_along_y(x, y, step, data)
    c = 0 if x < step and y else data[y - 1][x - step]
    pa = abs(b - c)
    pb = abs(a - c)
    pc = abs(a + b - 2 * c)
    if pa <= pb and pa <= pc:
        return a
    if pb <= pc:
        return b
    return c

def parse_to_rows(uncompressed_data, step):
    for i in range(0, len(uncompressed_data), step):
        yield uncompressed_data[i:i + step]

def check_file(filename):
    try:
        with open(filename, 'rb') as f:
            if f.read(8) != b'\x89PNG\r\n\x1a\n':
                raise NotImplementedError('Only PNG supported')
    except NotImplementedError as e:
        raise
\$\endgroup\$

1 Answer 1

1
\$\begingroup\$

In Image.get_rgb(), I would suggest using the floor division operator, changing

int(sum(row[x:x + 3]) / 3)

to

sum(row[x:x + 3]) // 3

as this will give the same result, but simplifies the line and avoids any use of float in the method, where you don't need it anyway. Of course, this would break if something caused row[] to contain a float.

\$\endgroup\$

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.