Python – jak przyspieszyć algorytm na przykładzie importu pliku PDF

Python – jak przyspieszyć algorytm na przykładzie importu pliku PDF

Dlaczego nasz kod często nie jest optymalny?

Istnieje wiele przyczyn, z powodu których nie tworzymy optymalnego kodu. Pierwsza, jaka przychodzi mi na myśl to krótkie terminy niepozwalające rozwinąć skrzydeł i dopracować szczegółów. Kolejną może być fakt, że klient nie zawsze oczekuje i potrzebuje kodu wykonującego się w ułamku sekundy. W sytuacji, gdy nasz algorytm będzie wykorzystywany stosunkowo rzadko bardziej opłacalne może okazać się czekanie kilku(nastu) sekund niż ponoszenie większych kosztów. Ostatnią przyczyną, o jakiej chciałbym wspomnieć są nasze umiejętności. Kto nigdy nie poczuł zażenowania analizując własny kod sprzed kilku miesięcy, a tym bardziej lat niech pierwszy rzuci pendrivem.

Jak optymalizować kod w pythonie i nie tylko?

Do wyboru mamy dwie opcje. Pierwsza z nich to usprawnienie istniejącego algorytmu, natomiast druga – zazwyczaj prostsza i dająca lepsze efekty – to napisanie funkcji od nowa. W poniższym przykładzie zaprezentuję obydwa podejścia. Drugie podejście bywa szczególnie owocne po latach, ponieważ w czasie, który minął od stworzenia kodu mogły pojawić się nowe, przydatne biblioteki.

Przykład praktyczny - import pliku PDF

Zadanie postawione przez klienta brzmi następująco: „Przygotujcie mi funkcję, która wczyta plik PDF, następnie podzieli go na pojedyncze strony i doda do istniejącego raportu jako załącznik.”. Podczas testowania rozwiązań wykorzystałem 13-stronnicowy plik PDF dostarczony przez klienta.

Na początku nasz algorytm wygląda następująco:


def pdf_upload (filename, data):
    images = []
    created_jpgs = False
    with Image(filename=filename, resolution=300) as img:
        images = []
        if len(img.sequence) > 1:
           for x in img.sequence:
              path = '{0}-{1.index}.jpg'.format(data.full_path.replace('.jpg', ''), x)
              convert_image(images, x, path)
        else:
           path = '{0}-{1}.jpg'.format(data.full_path.replace('.jpg', ''), 0)
           convert_image(images, img, path)
        return images

 


def convert_image(images, img, path):
    """Postprocessing photo."""
    images.append(path)
    img_page = Image(image=img)
    img_page.compression_quality = 20
    img_page.resize(2000, 2820)  # zachowuje proporcje formatu A
    img_page.alpha_channel = 'remove'
    img_page.save(filename=path)

Pseudokod:

- wczytaj PDF i „zeskanuj go” z DPI 300
- sprawdź czy liczba stron jest większa niż 1
- określ ścieżkę do pliku i przekonwertuj każdą stronę przy pomocy funkcji ‘convert_image’
- jeśli nie użyj innej nazwy, a następnie przekonwertuj stronę

Czas wykonywania tego algorytmu wynosi aż 163s, dla DPI 200 maleje do 17.2s, ale w przypadku niektórych PDFów jakość może okazać się niewystarczająca.

Podejście pierwsze – wykorzystując posiadany kod


def pdf_upload_fast_and_furious(filename, data):
    images = []
    pdf = PdfFileReader(filename)
    for page in range(pdf.getNumPages()):
        pdf_writer = PdfFileWriter()
        pdf_writer.addPage(pdf.getPage(page))
        output = f'{filename.replace(".pdf", "")}-{page}.pdf'
        with open(output, 'wb') as output_pdf:
            pdf_writer.write(output_pdf)
        with Image(filename=output, resolution=300) as img:
            path = f'{data.full_path.replace(".jpg", "")}-{page}.jpg'
            images.append(path)
            convert_image(images, img, path)
            os.remove(output)
    return images

Pseudokod:

- dla każdej strony w dokumencie wykonaj:
- wczytaj stronę do pamięci
- zapisz stronę jako PDF
- wczytaj stronę „skanując” ją z DPI 300
- określ ścieżkę do pliku i przekonwertuj go znaną funkcją ‘convert_image’
- usuń stronę w PDF

Tak zapisany kod wykonuje się 24.5s, co oznacza 6.8x szybszy algorytm niż na początku.

Wersja ostateczna – z wykorzystaniem biblioteki pdf2image


def pdf_upload_2_fast_2_furious(filename, data):
    """Upload drawing in high quality."""
    with open(filename, 'rb') as filehandle:
        pdf = PdfFileReader(filehandle)
        pages = pdf.getNumPages()
    with tempfile.TemporaryDirectory() as path:
        images_from_path = convert_from_path(
            filename,
            output_folder=path,
            last_page=pages,
            dpi= 300,
            first_page=1,
            thread_count=1,
        )
    images = []
    for index, page in enumerate(images_from_path):
        path = f'{data.full_path.replace(".jpg", "")}-{index}.jpg'
        page.save(path, 'JPEG')
        images.append(path)
    return images

Pseudokod:

- wczytaj plik do pamięci
- pobierz liczbę stron
- użyj funkcji ‘convert_from_path’ , aby podzielić plik na pojedyncze JPG
- zapisz pojedyncze JPG jako pliki

Tym razem algorytm wykonuje się w zaledwie 9.2 sekundy, ale istnieje możliwość przyspieszenia go poprzez zwiększenie liczby wątków biorących udział w operacji. Po wyborze dwóch wątków czas wykonania to 7.4s natomiast przy czterech 7.7s. Oznacza to, że przy wykorzystaniu jednego procesora logicznego nasz algorytm przyspieszył prawie 18-krotnie.