diff --git a/.github/workflows/python_linter.yaml b/.github/workflows/python_linter.yaml index 291339e..894a60b 100644 --- a/.github/workflows/python_linter.yaml +++ b/.github/workflows/python_linter.yaml @@ -32,3 +32,6 @@ jobs: - name: Run flake8 run: make flake8 + + - name: Run pylint + run: make pylint diff --git a/.pylintrc b/.pylintrc new file mode 100644 index 0000000..a63f80b --- /dev/null +++ b/.pylintrc @@ -0,0 +1,2 @@ +[FORMAT] +max-line-length=120 \ No newline at end of file diff --git a/Makefile b/Makefile index 290f0ca..37110bd 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -all: +all: clang-tidy mypy ruff flake8 pylint echo 'All' python-install: @@ -23,4 +23,7 @@ ruff: ruff check ./src/python/ flake8: - flake8 ./src/python/ + flake8 --ignore=E127 ./src/python/ + +pylint: + pylint ./src/python/ diff --git a/setup.cfg b/setup.cfg index 41a8d88..f67aa72 100644 --- a/setup.cfg +++ b/setup.cfg @@ -35,10 +35,10 @@ console_scripts = [options.extras_require] development = - mypy==1.18.2, - ruff==0.14.6, - flake8==7.3.0, - pylint==4.0.2, + mypy==1.18.2 + ruff==0.14.6 + flake8==7.3.0 + pylint==4.0.2 nbqa==1.9.1 pytest==9.0.1 diff --git a/src/python/app/command/io.py b/src/python/app/command/io.py index c702c6d..6efd54d 100644 --- a/src/python/app/command/io.py +++ b/src/python/app/command/io.py @@ -1,8 +1,12 @@ +"""Module for input/output operations related to parsing commandline""" + from sys import stdin, stdout from typing import BinaryIO def map_input(input_source: str) -> BinaryIO: + """Maps input source to stdin or to the file path""" + if input_source is None: return stdin.buffer @@ -10,6 +14,8 @@ def map_input(input_source: str) -> BinaryIO: def map_output(output_source: str) -> BinaryIO: + """Maps output source to stdout or to the file path""" + if output_source is None: return stdout.buffer diff --git a/src/python/app/command/parser.py b/src/python/app/command/parser.py index 5d06ef8..90a15ba 100644 --- a/src/python/app/command/parser.py +++ b/src/python/app/command/parser.py @@ -1,27 +1,23 @@ +"""Module containing functions that provide functionality related to commandline arguments parsing""" + from argparse import ArgumentParser, Namespace from typing import Callable from app.command.io import map_input, map_output from app.io.format_factory import get_reader_from_format, KnownFormat, get_writer_from_format -from app.operation.bgr2rgb import BGR2RGBOperation -from app.operation.flip import FlipOperation -from app.operation.grayscale import GrayscaleOperation -from app.operation.histogram_equalization import HistogramEqualizationOperation -from app.operation.identity import IdentityOperation -from app.operation.operation import Operation -from app.operation.roll import RollOperation - -from app.operation.rotate90 import Rotate90Operation +from app.operation import Rotate90, Identity, Flip, BGR2RGB, Roll, Grayscale, HistogramEqualization, IOperation def get_parser() -> ArgumentParser: + """Functions that initialises the Argument Parser""" + parser = ArgumentParser(prog='PROG', description='Image CLI that performs different image operations like scaling, rotating etc') - parser.add_argument('input', + parser.add_argument('--input', nargs='?', default=None, help='program input') - parser.add_argument('output', + parser.add_argument('--output', nargs='?', default=None, help='program output') @@ -32,7 +28,7 @@ def get_parser() -> ArgumentParser: help='program output') subparser = parser.add_subparsers(required=True, - help='Command to be performed on an image') + help='Command or operation to be performed on an image') for operation_class in available_commands(): operation = operation_class() @@ -46,7 +42,8 @@ def get_parser() -> ArgumentParser: return parser -def prepare_command(command: Operation) -> Callable[[Namespace], int]: +def prepare_command(command: IOperation) -> Callable[[Namespace], int]: + """Function that decorates the operation in order to provide input and output to it""" def wrapper(args: Namespace) -> int: data_format = KnownFormat.from_string(args.format) @@ -64,12 +61,14 @@ def wrapper(args: Namespace) -> int: def available_commands(): + """Function that returns all the supported commandline operations by the program""" + return [ - Rotate90Operation, - IdentityOperation, - FlipOperation, - BGR2RGBOperation, - RollOperation, - GrayscaleOperation, - HistogramEqualizationOperation, + Rotate90, + Identity, + Flip, + BGR2RGB, + Roll, + Grayscale, + HistogramEqualization, ] diff --git a/src/python/app/error/app_exception.py b/src/python/app/error/app_exception.py index 4c31690..6a3c639 100644 --- a/src/python/app/error/app_exception.py +++ b/src/python/app/error/app_exception.py @@ -1,2 +1,5 @@ +"""Module providing root class for all exceptions in this pacakge""" + + class AppException(Exception): - pass + """Root of the exceptions to provided by this package""" diff --git a/src/python/app/error/invalid_format_exception.py b/src/python/app/error/invalid_format_exception.py index 19ad924..9970750 100644 --- a/src/python/app/error/invalid_format_exception.py +++ b/src/python/app/error/invalid_format_exception.py @@ -1,5 +1,13 @@ +"""Module providing invalid format exception""" + from app.error.app_exception import AppException -class UnknownFormatException(AppException): - pass +class InvalidFormatException(AppException): + """Class that signals that the format is known, but the is some error in the binary related to the image""" + + def __init__(self, message: str) -> None: + self.message = message + + def __str__(self) -> str: + return self.message diff --git a/src/python/app/error/unknown_format_exception.py b/src/python/app/error/unknown_format_exception.py index a142965..8076291 100644 --- a/src/python/app/error/unknown_format_exception.py +++ b/src/python/app/error/unknown_format_exception.py @@ -1,7 +1,10 @@ +"""Module providing unknown format exception""" + from app.error.app_exception import AppException class UnknownFormatException(AppException): + """The image is provided in unsupported format""" def __init__(self, data_format: str) -> None: self.data_format = data_format diff --git a/src/python/app/image/image.py b/src/python/app/image/image.py index a8e43c0..e95f742 100644 --- a/src/python/app/image/image.py +++ b/src/python/app/image/image.py @@ -1,3 +1,5 @@ +"""Module implementing the image class used in the operations""" + from dataclasses import dataclass from typing import final @@ -7,4 +9,6 @@ @final @dataclass(slots=True) class Image: + """Class that stores the image object as a numpy array""" + data: np.ndarray diff --git a/src/python/app/imcli.py b/src/python/app/imcli.py index a6f1346..458e4c4 100644 --- a/src/python/app/imcli.py +++ b/src/python/app/imcli.py @@ -1,9 +1,10 @@ -"""Main entrypoint for imcli program""" +"""Main module for imcli program entrypoint""" from app.command.parser import get_parser def main() -> None: + """Main entrypoint for imcli program""" args = get_parser().parse_args() return args.func(args) diff --git a/src/python/app/io/bmp.py b/src/python/app/io/bmp.py index 8f8cb71..71e0973 100644 --- a/src/python/app/io/bmp.py +++ b/src/python/app/io/bmp.py @@ -1,15 +1,21 @@ +"""Module providing serialization and deserialization for BMP format (https://en.wikipedia.org/wiki/BMP_file_format)""" + import struct from enum import IntEnum -from typing import final, BinaryIO, override +from typing import final, BinaryIO, override, ClassVar +from dataclasses import dataclass import numpy as np +from app.error.invalid_format_exception import InvalidFormatException from app.image.image import Image -from app.io.format_reader import FormatReader -from app.io.format_writer import FormatWriter +from app.io.format_reader import IFormatReader +from app.io.format_writer import IFormatWriter + +class DBIHeaderType(IntEnum): + """Enum containing all the possible DBI headers and their lengths""" -class DBIHeader(IntEnum): BITMAP_CORE_HEADER = 12 OS22X_BITMAP_HEADER = 16 BITMAP_INFO_HEADER = 40 @@ -19,22 +25,59 @@ class DBIHeader(IntEnum): BITMAP_V5_HEADER = 124 +class Signature(IntEnum): + """Enum containing all possible signature values""" + + BM = 16973 + BA = 16961 + CI = 17225 + CP = 17232 + IC = 18755 + PT = 20564 + + +@dataclass(slots=True) +class BitmapFileHeader: + """Implements the struct for the """ + + HEADER_LENGTH: ClassVar[int] = 14 + + signature: int + file_size: int + reserved_1: int + reserved_2: int + file_offset_to_pixel_array: int + + @classmethod + def from_bytes(cls, data: bytes) -> 'BitmapFileHeader': + if len(data) < cls.HEADER_LENGTH: + raise InvalidFormatException(f"Header too short, received: {len(data)} instead of {cls.HEADER_LENGTH}") + + return cls(*struct.unpack('HIHHI', data)) + + def __post_init__(self) -> None: + if self.signature not in set(e.value for e in Signature): + raise InvalidFormatException("Invalid signature") + + if self.file_size < self.HEADER_LENGTH + DBIHeaderType.BITMAP_CORE_HEADER: + raise InvalidFormatException("File too short to parse the next data") + + if self.file_offset_to_pixel_array > self.file_size: + raise InvalidFormatException("Invalid offset to pixel array") + + +@dataclass(slots=True) +class BMP: + pass + + @final -class BMPReader(FormatReader): +class BMPReader(IFormatReader): # pylint: disable=too-few-public-methods + """Class that deserializes BMP format to Image""" @override def read_format(self, file: BinaryIO) -> Image: - header = file.read(14) - assert len(header) == 14 - - signature_first, signature_second = struct.unpack('BB', header[:2]) - # signature_first, signature_second = hex(signature_first), hex(signature_second) - assert (signature_first, signature_second) in [(0x42, 0x4D), (0x42, 0x41), (0x43, 0x49), (0x43, 0x50), - (0x49, 0x43), (0x50, 0x54)] - - file_size, = struct.unpack('I', header[2:6]) - res1, res2 = struct.unpack('HH', header[6:10]) - file_offset_to_pixel_array, = struct.unpack('I', header[10:]) + header = BitmapFileHeader.from_bytes(data=file.read(BitmapFileHeader.HEADER_LENGTH)) dib_header_size = file.read(4) dib_header_size, = struct.unpack('I', dib_header_size) @@ -60,18 +103,13 @@ def read_format(self, file: BinaryIO) -> Image: image_bytes += file.read(row_size)[:row_size - padding] num_colors_end = bits_per_pixel // 8 - return Image(data=np.flip( - np.flip( - np.frombuffer(bytes(image_bytes), - dtype=np.uint8).reshape(image_height, image_width, num_colors_end)[:, :, ::-1], - axis=0 - ), - axis=1) - ) + return Image(data=np.frombuffer(bytes(image_bytes), + dtype=np.uint8).reshape(image_height, image_width, num_colors_end)) @final -class BMPWriter(FormatWriter): +class BMPWriter(IFormatWriter): # pylint: disable=too-few-public-methods + """Class that serializes Image to BMP format""" def write_format(self, file: BinaryIO, input_image: Image) -> None: input_arr = input_image.data diff --git a/src/python/app/io/format_factory.py b/src/python/app/io/format_factory.py index b9373cb..0f65b2e 100644 --- a/src/python/app/io/format_factory.py +++ b/src/python/app/io/format_factory.py @@ -1,16 +1,26 @@ +"""Module implementing routing functions for input image formats""" + import enum from app.error.unknown_format_exception import UnknownFormatException from app.io.bmp import BMPReader, BMPWriter -from app.io.format_reader import FormatReader -from app.io.format_writer import FormatWriter +from app.io.format_reader import IFormatReader +from app.io.format_writer import IFormatWriter class KnownFormat(enum.Enum): + """Enum providing all the image formats that are supported by the program""" + BMP = 0 + PNG = enum.auto() + PBM = enum.auto() + PGM = enum.auto() + PPM = enum.auto() @classmethod def from_string(cls, data_format: str) -> 'KnownFormat': + """Additional constructor for this class""" + match data_format: case 'bmp': return cls.BMP @@ -20,14 +30,19 @@ def from_string(cls, data_format: str) -> 'KnownFormat': @classmethod def get_available_formats(cls) -> list[str]: + """Return default format for commandline to use""" + return [e.name.lower() for e in cls] @classmethod def default(cls) -> 'KnownFormat': + """Return default format for commandline to use""" + return KnownFormat.BMP -def get_reader_from_format(data_format: KnownFormat) -> FormatReader: +def get_reader_from_format(data_format: KnownFormat) -> IFormatReader: + """Factory function for format reader""" match data_format: case KnownFormat.BMP: @@ -37,7 +52,8 @@ def get_reader_from_format(data_format: KnownFormat) -> FormatReader: assert False, "unreachable" -def get_writer_from_format(data_format: KnownFormat) -> FormatWriter: +def get_writer_from_format(data_format: KnownFormat) -> IFormatWriter: + """Factory function for format writer""" match data_format: case KnownFormat.BMP: diff --git a/src/python/app/io/format_reader.py b/src/python/app/io/format_reader.py index e95f6f0..05f062a 100644 --- a/src/python/app/io/format_reader.py +++ b/src/python/app/io/format_reader.py @@ -1,11 +1,14 @@ +"""Module providing deserialization interface for binary streams""" + from abc import abstractmethod, ABC from typing import BinaryIO from app.image.image import Image -class FormatReader(ABC): +class IFormatReader(ABC): # pylint: disable=too-few-public-methods + """Interface that helps to deserialize image formats""" @abstractmethod def read_format(self, file: BinaryIO) -> Image: - pass + """Abstract method that deserialize image to binary stream""" diff --git a/src/python/app/io/format_writer.py b/src/python/app/io/format_writer.py index 3ad846a..d7e2b17 100644 --- a/src/python/app/io/format_writer.py +++ b/src/python/app/io/format_writer.py @@ -1,11 +1,14 @@ +"""Module providing serialization interface for binary streams""" + from abc import ABC, abstractmethod from typing import BinaryIO from app.image.image import Image -class FormatWriter(ABC): +class IFormatWriter(ABC): # pylint: disable=too-few-public-methods + """Interface that helps to serialize image formats""" @abstractmethod def write_format(self, file: BinaryIO, input_image: Image) -> None: - pass + """Abstract method that serializes image to binary stream""" diff --git a/src/python/app/operation/__init__.py b/src/python/app/operation/__init__.py index e69de29..3d88547 100644 --- a/src/python/app/operation/__init__.py +++ b/src/python/app/operation/__init__.py @@ -0,0 +1,19 @@ +"""Module providing import convenience for operations""" + +from app.operation.bgr2rgb import BGR2RGB +from app.operation.flip import Flip +from app.operation.grayscale import Grayscale +from app.operation.histogram_equalization import HistogramEqualization +from app.operation.identity import Identity +from app.operation.ioperation import IOperation +from app.operation.roll import Roll +from app.operation.rotate90 import Rotate90 + +__all__ = [BGR2RGB.__name__, + Flip.__name__, + Grayscale.__name__, + HistogramEqualization.__name__, + Identity.__name__, + IOperation.__name__, + Roll.__name__, + Rotate90.__name__] diff --git a/src/python/app/operation/bgr2rgb.py b/src/python/app/operation/bgr2rgb.py index ba91ee1..9bd2fcc 100644 --- a/src/python/app/operation/bgr2rgb.py +++ b/src/python/app/operation/bgr2rgb.py @@ -1,12 +1,15 @@ +"""Module providing BGR to RGB converter""" + from argparse import Namespace, ArgumentParser -from typing import final +from typing import final, override from app.image.image import Image -from app.operation.operation import Operation +from app.operation.ioperation import IOperation @final -class BGR2RGBOperation(Operation): +class BGR2RGB(IOperation): + """Converts the Blue, Green, Red channelled image to Red, Green, Blue, does nothing for grayscale iamges""" @classmethod def name(cls) -> str: @@ -20,5 +23,6 @@ def help(cls) -> str: def parser(cls, parser: ArgumentParser) -> None: pass + @override def __call__(self, args: Namespace, input_image: Image) -> Image: return Image(input_image.data[:, :, ::-1]) diff --git a/src/python/app/operation/flip.py b/src/python/app/operation/flip.py index dd2aee8..5158db8 100644 --- a/src/python/app/operation/flip.py +++ b/src/python/app/operation/flip.py @@ -1,14 +1,17 @@ +"""Module that implements the Flip Operation""" + from argparse import Namespace, ArgumentParser -from typing import final +from typing import final, override import numpy as np from app.image.image import Image -from app.operation.operation import Operation +from app.operation.ioperation import IOperation @final -class FlipOperation(Operation): +class Flip(IOperation): + """Class that flips the image horizontally or vertically""" @classmethod def name(cls) -> str: @@ -28,6 +31,7 @@ def parser(cls, parser: ArgumentParser) -> None: dest='vertical', action='store_true') + @override def __call__(self, args: Namespace, input_image: Image) -> Image: if args.horizontal: return Image(np.flip(input_image.data, axis=1)) diff --git a/src/python/app/operation/grayscale.py b/src/python/app/operation/grayscale.py index 512d5be..fef8846 100644 --- a/src/python/app/operation/grayscale.py +++ b/src/python/app/operation/grayscale.py @@ -1,14 +1,17 @@ +"""Module providing implementation of the Grayscale operation""" + from argparse import Namespace, ArgumentParser -from typing import final +from typing import final, override import numpy as np from app.image.image import Image -from app.operation.operation import Operation +from app.operation.ioperation import IOperation @final -class GrayscaleOperation(Operation): +class Grayscale(IOperation): + """Implements the grayscale operation, does nothing on grey images""" @classmethod def name(cls) -> str: @@ -22,13 +25,18 @@ def help(cls) -> str: def parser(cls, parser: ArgumentParser) -> None: pass + @override def __call__(self, args: Namespace, input_image: Image) -> Image: - return Image(np.repeat(np.expand_dims(np.clip( - 0.2126 * input_image.data[:, :, 0] - + 0.7152 * input_image.data[:, :, 1] - + 0.0722 * input_image.data[:, :, 1], - a_min=0., - a_max=255.).astype(np.uint8), - axis=-1), - repeats=3, - axis=-1)) + if input_image.data.shape[-1] == 1: + return input_image + + result_image = 0.2126 * input_image.data[:, :, 0] \ + + 0.7152 * input_image.data[:, :, 1] \ + + 0.0722 * input_image.data[:, :, 2] + + return Image(data=np.repeat(a=np.expand_dims(a=np.clip(a=result_image, + a_min=0., + a_max=255.).astype(np.uint8), + axis=-1), + repeats=3, + axis=-1)) diff --git a/src/python/app/operation/histogram_equalization.py b/src/python/app/operation/histogram_equalization.py index 66961e7..b1c167d 100644 --- a/src/python/app/operation/histogram_equalization.py +++ b/src/python/app/operation/histogram_equalization.py @@ -1,14 +1,17 @@ +"""Module that performs histogram equalisation operation""" + from argparse import Namespace, ArgumentParser -from typing import final +from typing import final, override import numpy as np from app.image.image import Image -from app.operation.operation import Operation +from app.operation.ioperation import IOperation @final -class HistogramEqualizationOperation(Operation): +class HistogramEqualization(IOperation): + """Class that performs histogram equalisation on the image""" @classmethod def name(cls) -> str: @@ -22,16 +25,19 @@ def help(cls) -> str: def parser(cls, parser: ArgumentParser) -> None: pass + @override def __call__(self, args: Namespace, input_image: Image) -> Image: output_image = np.empty_like(input_image.data) - output_image[:, :, 0] = self.equalize_chanel(input_image.data[:, :, 0]) - output_image[:, :, 1] = self.equalize_chanel(input_image.data[:, :, 1]) - output_image[:, :, 2] = self.equalize_chanel(input_image.data[:, :, 2]) + for channel in range(input_image.data.shape[-1]): + output_image[:, :, channel] = self.equalize_chanel(input_image.data[:, :, channel]) return Image(data=output_image) - def equalize_chanel(self, image: np.ndarray) -> np.ndarray: + @staticmethod + def equalize_chanel(image: np.ndarray) -> np.ndarray: + """This method performs histogram equalisation on one input channel of the image""" + image_histogram, bins = np.histogram(image.flatten(), 256, density=True) cdf = image_histogram.cumsum() cdf = (256 - 1) * cdf / cdf[-1] diff --git a/src/python/app/operation/identity.py b/src/python/app/operation/identity.py index 7c7500e..1860eb1 100644 --- a/src/python/app/operation/identity.py +++ b/src/python/app/operation/identity.py @@ -1,12 +1,15 @@ +"""Module that implements the Identity operation""" + from argparse import Namespace, ArgumentParser -from typing import final +from typing import final, override from app.image.image import Image -from app.operation.operation import Operation +from app.operation.ioperation import IOperation @final -class IdentityOperation(Operation): +class Identity(IOperation): + """It is an identity operation meaning it returns the same thing that it got as an input""" @classmethod def name(cls) -> str: @@ -20,5 +23,6 @@ def help(cls) -> str: def parser(cls, parser: ArgumentParser) -> None: pass + @override def __call__(self, args: Namespace, input_image: Image) -> Image: return input_image diff --git a/src/python/app/operation/operation.py b/src/python/app/operation/ioperation.py similarity index 52% rename from src/python/app/operation/operation.py rename to src/python/app/operation/ioperation.py index c605a28..dc78b16 100644 --- a/src/python/app/operation/operation.py +++ b/src/python/app/operation/ioperation.py @@ -1,26 +1,29 @@ -from abc import ABC, abstractmethod -from argparse import Namespace, ArgumentParser - -from app.image.image import Image - - -class Operation(ABC): - - @classmethod - @abstractmethod - def name(cls) -> str: - pass - - @classmethod - @abstractmethod - def help(cls) -> str: - pass - - @classmethod - @abstractmethod - def parser(cls, parser: ArgumentParser) -> None: - pass - - @abstractmethod - def __call__(self, args: Namespace, input_image: Image) -> Image: - pass +"""Module that provides commandline Operation abstract class""" + +from abc import ABC, abstractmethod +from argparse import Namespace, ArgumentParser + +from app.image.image import Image + + +class IOperation(ABC): + """Abstract class implementing Operation interface""" + + @classmethod + @abstractmethod + def name(cls) -> str: + """Abstract method that initialises the commandline name of the operation""" + + @classmethod + @abstractmethod + def help(cls) -> str: + """Abstract method that initialises the commandline help message of the operation""" + + @classmethod + @abstractmethod + def parser(cls, parser: ArgumentParser) -> None: + """Abstract method that initialises subcommand argument parser""" + + @abstractmethod + def __call__(self, args: Namespace, input_image: Image) -> Image: + pass diff --git a/src/python/app/operation/roll.py b/src/python/app/operation/roll.py index b40cfdd..d27352d 100644 --- a/src/python/app/operation/roll.py +++ b/src/python/app/operation/roll.py @@ -1,14 +1,17 @@ +"""Module providing vertical or horizonal shift of the image""" + from argparse import ArgumentParser, Namespace -from typing import final +from typing import final, override import numpy as np from app.image.image import Image -from app.operation.operation import Operation +from app.operation.ioperation import IOperation @final -class RollOperation(Operation): +class Roll(IOperation): + """Performs vertical or horizontal shift in pixels on the image""" @classmethod def name(cls) -> str: @@ -29,6 +32,7 @@ def parser(cls, parser: ArgumentParser) -> None: dest='hor_shift', type=int) + @override def __call__(self, args: Namespace, input_image: Image) -> Image: return Image(data=np.roll( np.roll( diff --git a/src/python/app/operation/rotate90.py b/src/python/app/operation/rotate90.py index a0ce731..133beae 100644 --- a/src/python/app/operation/rotate90.py +++ b/src/python/app/operation/rotate90.py @@ -1,14 +1,17 @@ +"""Module containing the clockwise rotation of the image""" + from argparse import ArgumentParser, Namespace -from typing import final +from typing import final, override import numpy as np from app.image.image import Image -from app.operation.operation import Operation +from app.operation.ioperation import IOperation @final -class Rotate90Operation(Operation): +class Rotate90(IOperation): + """Performs 90-degree clockwise rotation of the image""" @classmethod def name(cls) -> str: @@ -23,8 +26,8 @@ def parser(cls, parser: ArgumentParser) -> None: parser.add_argument('--rotations', default=1, type=int, - help='number of full rotations (clockwise), negative numbers ' - ) + help='number of full rotations (clockwise), negative numbers ') + @override def __call__(self, args: Namespace, input_image: Image) -> Image: return Image(np.rot90(input_image.data, k=args.rotations))