Coding shadows

Here's a small project I wrote in a few hours. It's a Python3 program that calculates and displays shadows based on information read from a JSON file.

Firstly, the user is presented with a list of files ending in .json in the current directory. They must enter a valid filename to proceed.

The JSON file is parsed. It contains the resolution of the image, the location of the light in the scene and the location and radius of the circles (which cast shadows) in the image. Each light and shape in the scene instantiate a new object from an appropriate class, with the afforementioned data stored in the object's attributes. These objects are appended to an array.

The program then iterates through each light in the lights array and each pixel in the x and y axis (from 0 to the total width and height of the image). It determines if the path from the light source to the pixel in question is obstructed by a shape.

To accomplish this, the program determines the equation of the straight line from the light source to the pixel ie y=mx+c. It then iterates through each x value between both locations and calculates the corresponding y value based on the equation of the line. If the calculated (x,y) value is closer in distance to the center of any shape than the shape's radius, then there is an intersection and the line would broken by a shape -- the pixel is in shadow.

The distance from the pixel to the light source is then calculated and an inverse proportionality formula used to find the intensity of the shadowed pixel.

Here's some example scenes:

Two circles

Light changes position

Smaller circle behind larger circle

Large circle behind smaller circle

Although it is functional, there are a number of issues with this algorithm.

Firstly, going through every pixel in the image one-by-one is horribly inefficient. For a 200x200 image with one light and two shapes, it took as long as 6 seconds to calculate and render the final result. To improve this, I could employ heuristic rules to dramatically reduce the number of pixels that would need to be looked at (using information about surrounding pixels to decide whether it is necessary to determine if a particular pixel is in shadow).

Secondly, it struggles if a shape is at an angle to the light source that is close to 0 degrees. This is likely due to the fact that the line equation between the two pixels produces y values that require a more precise grid.

Unusual effects

Overall, this project was successful but has taught me a great deal about how complicated and challenging it would be to write real-time graphics engines that run at 60Hz in 3D instead of 0.16Hz in 2D!

Source code:

from PIL import Image, ImageDraw
import os, json

class shape():
    def __init__(self):
        self.type = None
        self.center = []
        self.radius = None


class light():
    def __init__(self):
        self.location = []


def startup():
    availableFiles = os.listdir()
    print("Available files:")
    for file in availableFiles:
        if ".json" in file:
            print(file)

    while fileName == "":
        fileName = input("\nEnter filename: ")
        if ".json" not in fileName:
            fileName += ".json"
        if fileName not in availableFiles:
            print("Invalid filename")
            fileName = ""

    with open(fileName, "r") as file:
        data = json.load(file)

    shapes = []
    lights = []

    for item in data["shapes"]:
        tempObject = shape()
        tempObject.type = data["shapes"][item]["type"]
        tempObject.center = data["shapes"][item]["center"]
        tempObject.radius = data["shapes"][item]["radius"]
        shapes.append(tempObject)

    for item in data["lights"]:
        tempObject = light()
        tempObject.location = data["lights"][item]["location"]
        lights.append(tempObject)

    resolution = data["metadata"]["resolution"]
    return resolution, shapes, lights


def intersection(point, shapes):
    for shape in shapes:
        if shape.type == "circle":
            if pythag(shape.center, point) < shape.radius:
                return True


def tracing(l1, l2, shapes, resolution):
    if l1 == l2:
        return False

    elif l2[0] - l1[0] == 0:
        for y in range(l1[1], l2[1]):
            if intersection((l1[0], y), shapes):
                return False

    elif l2[1] - l1[1] == 0:
        for x in range(l1[0], l2[0]):
            if intersection((x, l1[1]), shapes):
                return False

    else:
        dydx = (l2[1] - l1[1])/(l2[0] - l1[0])
        yintercept = l1[1] - (dydx * l1[0])

        if l2[0] < l1[0]:
            t0, t1 = l2[0], l1[0]
        else:
            t0, t1 = l1[0], l2[0]

        for x in range(t0, t1):
            y = (dydx * x) + yintercept
            if y < 0 or y > resolution[1]:
                pass
            elif intersection((x, y), shapes):
                return False

    return True


def pythag(l1, l2):
    return ((l2[0] - l1[0])**2 + (l2[1] - l1[1])**2)**0.5


def main():
    resolution, shapes, lights = startup()
    im = Image.new("RGB", resolution, "white")
    draw = ImageDraw.Draw(im)

    for source in lights:
        for x in range(0, resolution[0]):
            for y in range(0, resolution[1]):
                if not tracing(source.location, (x, y), shapes, resolution):
                    intensity = 10000 * (1/pythag(source.location, (x, y)))
                    if intensity > 100:
                        intensity = 100
                    else:
                        intensity = 100 - intensity
                    draw.point((x, y), fill = "hsl(0, 0%, {0}%)".format(intensity))

        centerX, centerY, radius = source.location[0], source.location[1], 5
        draw.ellipse((centerX - radius, centerY - radius, centerX + radius, centerY + radius), fill = "blue")

    for item in shapes:
        centerX, centerY, radius = item.center[0], item.center[1], item.radius
        draw.ellipse((centerX - radius, centerY - radius, centerX + radius, centerY + radius), fill = "red")

    im.show()


main()

Comments

Popular posts from this blog

Writing a MicroPython Pi-hole for the ESP8266

The Casio F-91W

Mini server update