7
\$\begingroup\$

Why I made it

I wrote this script for one simple purpose: to Rickroll my friends.

How it works

This is the workflow:

  1. It fetches a .png frame from a PHP endpoint on a website I own. The frame number is provided through a q GET parameter.
  2. Instead of saving the downloaded frame to disk, the script keeps it in memory using Pillow.
  3. It converts the image to ASCII using ascii-magic and stores the result in a list.

There are 28 total frames. The script loops through all of them, incrementing q on each request, generates ASCII versions, then plays them in an endless loop with a short delay. It keeps running until manually stopped with Ctrl+C.

Problems

There are two issues. My question is focused on the first one:

  • The main issue is how choppy and flickery the animation looks. The rendering is inconsistent at higher playback speeds, and reducing the speed makes it smoother but still doesn’t eliminate the flicker.
  • The second issue is that the script takes around 30–40 seconds to generate all 28 ASCII frames before playback. I’m okay with this delay because it adds suspense.

Dependencies

  • Python 3
  • requests
  • pillow (Pillow)
  • ascii-magic
  • A Unix or Windows terminal (for clearing and redraw)

Code

Here is the Python script:

"""
Live ASCII Rickroll generator.

Downloads 28 PNG frames from a PHP endpoint, converts them to ASCII using
ascii-magic, stores them in memory, and plays them in a continuous loop.
"""

import os
import time
import requests
from io import BytesIO
from PIL import Image
from ascii_magic import AsciiArt

def clear_screen():
    """Clear the terminal screen based on the operating system."""
    if os.name == 'nt':
        os.system('cls')
    else:
        os.system('clear')

# Get terminal size
size = os.get_terminal_size()
width = size.columns
frame = []

try:
    # Loop to download and process images
    at_frame = 1
    while at_frame <= 28:
        try:
            url = f"http://chipz1.atwebpages.com/rick.php?q={at_frame}"
            response = requests.get(url, allow_redirects=True)
            response.raise_for_status()

            # Open image in memory
            img = Image.open(BytesIO(response.content))

            # Convert to ASCII and store
            frame.append(AsciiArt.from_pillow_image(img))

            at_frame += 1
        except OSError as e:
            print(f'Could not load the image, server said: {e}')

    # Infinite playback loop
    while True:
        frame_num = 0
        while frame_num < len(frame):
            frame[frame_num].to_terminal(columns=int(width / 2), width_ratio=2)
            frame_num += 1
            time.sleep(0.05)
            clear_screen()

except KeyboardInterrupt:
    clear_screen()
except Exception as e:
    print(f"An error has occurred: {e}")

And here is the PHP endpoint that serves each PNG frame:

<?php
$q = isset($_GET['q']) ? intval($_GET['q']) : 0;

if ($q >= 1 && $q <= 28) {
    $file = __DIR__ . "/frame{$q}.png";
    if (file_exists($file)) {
        header("Content-Type: image/png");
        readfile($file);
        exit;
    }
}

http_response_code(404);
echo "Not found";

End result

You just got Rickrolled by Potato12

\$\endgroup\$
10
  • 1
    \$\begingroup\$ wait... are we getting rick-rolled by that URL on line 32? it appears to redirect to various hosting sites regardless of the value of q... \$\endgroup\$ Commented Nov 19 at 22:19
  • 6
    \$\begingroup\$ Do you really need the PNG files? Why not convert to text files once and host those on your server? \$\endgroup\$ Commented Nov 20 at 9:48
  • 2
    \$\begingroup\$ I really recommend not hosting .png files at all, and instead hosting compressed video. It will be much more efficient. \$\endgroup\$ Commented Nov 20 at 20:55
  • 2
    \$\begingroup\$ Video compression will reduce transfer time. Transfer time will typically dominate compared to decode time, especially if you use a proper decoder that leverages your hardware. \$\endgroup\$ Commented Nov 21 at 2:31
  • 1
    \$\begingroup\$ You should also read about aasink, cacasink and aatv gstreamer plugins. \$\endgroup\$ Commented Nov 23 at 4:50

3 Answers 3

8
\$\begingroup\$

I first tried to fetch an image via my browser just to see what I would be displaying, but I am currently getting redirected. I will certainly try again later and I may have more to say when I am able to execute your code. For now here are some suggestions:

Endless Loop Issue

With your current code, if there is an error loading an image (and there was an exception raised due to the server being down), at_frame never gets incremented and an attempt is made to reload the same page over and over again in an endless loop. The best course of action on such an error would be to terminate the script.

Be More Pythonic

  • You have a loop variable at_frame (would frame_num be a better name?) that is initialized, tested and incremented. The Pythonic way of handling such a loop would be to use the range built-in function:
for at_frame in range(1, 29):
    ...

at_frame will take on successive integer values between 1 and 28. This will also solve the "endless loop issue" described above, but my suggestion would be to terminate the script on such an error.

  • Your "infinite playback loop" controlled by variable frame_num can also be made more Pythonic in a similar way.

  • You have the expression int(width / 2). This should be instead width // 2.

Use Multithreading to Improve Image Download Time

Investigate either the concurrent.futures.ThreadPool or multiprocessing.pool.ThreadPool class to download multiple images concurrently.

Use a Session

Since you are doing multiple requests to the same site, it is recommended that you use a Session instance for making your get requests. See Advanced Usage.

Possible Performance Improvement

In your code a certain amount of calculations needs to be done executing frame.to_terminal(columns=n_columns, width_ratio=2). Looking at the implementation of metthod to_terminal I see:

def to_terminal(self, ...):
    art = self._img_to_art(...)
    print(art)
    return art

This suggests that we can process each frame initially to crate a new frame that can be directly outputted with a print statement.

Reduce Flicker

Instead of clearing your screen prior to outputting each frame, use an ASCII escape sequence to position the cursor to the first row of the terminal.

Putting It All Together

"""
Live ASCII Rickroll generator.

Downloads 28 PNG frames from a PHP endpoint, converts them to ASCII using
ascii-magic, stores them in memory, and plays them in a continuous loop.
"""

import os
import time
import requests
from io import BytesIO, StringIO
from multiprocessing.pool import ThreadPool
from functools import partial
from contextlib import redirect_stdout

from PIL import Image
from ascii_magic import AsciiArt

def main():
    def clear_screen():
        """Clear the terminal screen based on the operating system."""
        if os.name == 'nt':
            os.system('cls')
        else:
            os.system('clear')

    def download_image(session, frame_num):
        """Download and process a single frame."""
        try:
            url = f"http://chipz1.atwebpages.com/rick.php?q={frame_num}"
            response = session.get(url, allow_redirects=True)
            response.raise_for_status()

            # Open image in memory
            img = Image.open(BytesIO(response.content))

            # Convert to ASCII and return it
            return AsciiArt.from_pillow_image(img)
        except OSError as e:
            return e

    with requests.Session() as session, ThreadPool(28) as pool:
        worker = partial(download_image, session)
        frames = pool.map(worker, range(1, 29))

    # See if any exceptions returned:
    for frame_num, frame in enumerate(frames, 1):
        if isinstance(frame, Exception):
            print(f'Could not load the image for frame number {frame_num}, server said: {frame}')
            return

    # Get terminal size
    size = os.get_terminal_size()
    width = size.columns
    n_columns = width // 2

    # Convert frames for viewing:
    new_stdout = StringIO()
    with redirect_stdout(new_stdout):
        for frame_num in range(28):
            frames[frame_num] = frames[frame_num].to_terminal(columns=n_columns, width_ratio=2)
            new_stdout.seek(0, 0)  # have next frame write over current frame

    clear_screen()  # clear screen first
    try:
        # Loop to display images
        while True:
            for frame in frames:
                print("\033[1;1H", frame, sep="", flush=True)
                time.sleep(0.05)
    except KeyboardInterrupt:
        clear_screen()
    except Exception as e:
        print(f"An error has occurred: {e}")

if __name__ == '__main__':
    main()
\$\endgroup\$
5
  • \$\begingroup\$ Concerning the ASCII escape sequence, does it bring the cursor to the first column too? \$\endgroup\$ Commented Nov 20 at 12:50
  • 3
    \$\begingroup\$ Yes. See this: Ansi Escape Sequences. You can also just use "\033[H". \$\endgroup\$ Commented Nov 20 at 13:18
  • 1
    \$\begingroup\$ "Avoid clearing the screen between frames" was my first thought when reading there was a flicker problem, before I even read any code. \$\endgroup\$ Commented Nov 20 at 20:29
  • \$\begingroup\$ Thfirst time I ran it, being on one of raspberry pi's weakest models, I lagged so much I had a hard time writing this very comment. My raspi just isn't good at multitasking... \$\endgroup\$ Commented Nov 21 at 22:52
  • \$\begingroup\$ Its kind of funny, now that I think of it. \$\endgroup\$ Commented Nov 21 at 22:53
4
\$\begingroup\$

Portability

The clear_screen function is terrific. You partitioned the code into a function which also supports multiple operating systems. And you clearly described its purpose with a docstring.

You can even make it more DRY by using the code from your previous question:

os.system("cls" if os.name == "nt" else "clear")

try/except

The except statements are many lines away from the try lines.
PEP 8 recommends that you limit the try clause to the absolute minimum amount of code necessary to avoid masking bugs. It is hard to keep track of what line (or lines) are expected to result in the exception.

Magic number

In this line:

    while at_frame <= 28:

you could replace the number with a constant that has a meaningful name. For example:

    PNG_FRAMES = 28

    while at_frame <= PNG_FRAMES:
\$\endgroup\$
2
  • \$\begingroup\$ I might add PNG_FRAMES as one of the variables in the URI requested, since the php code would not currently go over 28 right now. (Or just take off its limit, actually) \$\endgroup\$ Commented Nov 20 at 13:59
  • 3
    \$\begingroup\$ os.system() is a pretty bad way to do anything that benefits from high performance. \$\endgroup\$ Commented Nov 20 at 20:31
3
\$\begingroup\$

Actually, let me put this as an answer. The fastest code is code you don't write at all. So why don't convert the PNG's once and host them as text on your server? Then the client is much simpler and faster. You can create a separate conversion utility from the code currently in your client.

\$\endgroup\$
8
  • 1
    \$\begingroup\$ I have thought of that, but once again, it is not really the problem. Although it would indeed make it faster to load, I would not be able to change the pictures for example. \$\endgroup\$ Commented Nov 20 at 12:41
  • 2
    \$\begingroup\$ Why wouldn't you be able to change the pictures? The ASCII form is just another representation, if you will, of the picture. If you now have rick00.png to rick27.png, you'd instead have the pre-ASCIIfied rick00.txt ... rick27.txt and load those from the client. Your client will be much simpler. You'd still have a script that generates these ASCII representations, just on the server side, once, instead of at the client every time. \$\endgroup\$ Commented Nov 20 at 13:14
  • 3
    \$\begingroup\$ @Chip01 You could use an animated PNG which would reduce the network requests and you could extract the number of frames from the file. \$\endgroup\$ Commented Nov 20 at 15:37
  • 3
    \$\begingroup\$ @Chip01 You're already dynamically generating the text from the images in the client. Why couldn't you simply do that in the server instead, caching after the first time they are generated? Updating the images would be as easy as changing the images and restarting the server. \$\endgroup\$ Commented Nov 20 at 19:31
  • 3
    \$\begingroup\$ @Chip01: So run it on your desktop and upload the 28 text files, instead of uploading 28 PNGs. (Or a JSON or some other format that has all 28 strings in one file, for a single HTTP request and one which benefits from DEFLATE or zstd compression finding redundancy across frames. That would allow the number of frames to be variable.) \$\endgroup\$ Commented Nov 21 at 13:17

You must log in to answer this question.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.