Generate random number outside of range in python

后端 未结 5 1263
盖世英雄少女心
盖世英雄少女心 2021-02-18 22:52

I\'m currently working on a pygame game and I need to place objects randomly on the screen, except they cannot be within a designated rectangle. Is there an easy way to do this

相关标签:
5条回答
  • 2021-02-18 23:17

    It requires a bit of thought to generate a uniformly random point with these constraints. The simplest brute force way I can think of is to generate a list of all valid points and use random.choice() to select from this list. This uses a few MB of memory for the list, but generating a point is very fast:

    import random
    
    screen_width = 1000
    screen_height = 800
    rect_x = 500
    rect_y = 250
    rect_width = 100
    rect_height = 75
    
    valid_points = []
    for x in range(screen_width):
        if rect_x <= x < (rect_x + rect_width):
            for y in range(rect_y):
                valid_points.append( (x, y) )
            for y in range(rect_y + rect_height, screen_height):
                valid_points.append( (x, y) )
        else:
            for y in range(screen_height):
                valid_points.append( (x, y) )
    
    for i in range(10):
        rand_point = random.choice(valid_points)
        print(rand_point)
    

    It is possible to generate a random number and map it to a valid point on the screen, which uses less memory, but it is a bit messy and takes more time to generate the point. There might be a cleaner way to do this, but one approach using the same screen size variables as above is here:

    rand_max = (screen_width * screen_height) - (rect_width * rect_height) 
    def rand_point():
        rand_raw = random.randint(0, rand_max-1)
        x = rand_raw % screen_width
        y = rand_raw // screen_width
        if rect_y <= y < rect_y+rect_height and rect_x <= x < rect_x+rect_width:
            rand_raw = rand_max + (y-rect_y) * rect_width + (x-rect_x)
            x = rand_raw % screen_width
            y = rand_raw // screen_width
        return (x, y)
    

    The logic here is similar to the inverse of the way that screen addresses are calculated from x and y coordinates on old 8 and 16 bit microprocessors. The variable rand_max is equal to the number of valid screen coordinates. The x and y co-ordinates of the pixel are calculated, and if it is within the rectangle the pixel is pushed above rand_max, into the region that couldn't be generated with the first call.

    If you don't care too much about the point being uniformly random, this solution is easy to implement and very quick. The x values are random, but the Y value is constrained if the chosen X is in the column with the rectangle, so the pixels above and below the rectangle will have a higher probability of being chosen than pizels to the left and right of the rectangle:

    def pseudo_rand_point():        
        x = random.randint(0, screen_width-1)
        if rect_x <= x < rect_x + rect_width: 
            y = random.randint(0, screen_height-rect_height-1)
            if y >= rect_y:
                y += rect_height
        else:
            y = random.randint(0, screen_height-1)
        return (x, y)
    

    Another answer was calculating the probability that the pixel is in certain regions of the screen, but their answer isn't quite correct yet. Here's a version using a similar idea, calculate the probability that the pixel is in a given region and then calculate where it is within that region:

    valid_screen_pixels = screen_width*screen_height - rect_width * rect_height
    prob_left = float(rect_x * screen_height) / valid_screen_pixels
    prob_right = float((screen_width - rect_x - rect_width) * screen_height) / valid_screen_pixels
    prob_above_rect = float(rect_y) / (screen_height-rect_height)
    def generate_rand():
        ymin, ymax = 0, screen_height-1
        xrand = random.random()
        if xrand < prob_left:
            xmin, xmax = 0, rect_x-1
        elif xrand > (1-prob_right):
            xmin, xmax = rect_x+rect_width, screen_width-1
        else:
            xmin, xmax = rect_x, rect_x+rect_width-1
            yrand = random.random()
            if yrand < prob_above_rect:
                ymax = rect_y-1
            else:
                ymin=rect_y+rect_height
        x = random.randrange(xmin, xmax)
        y = random.randrange(ymin, ymax)
        return (x, y)
    
    0 讨论(0)
  • 2021-02-18 23:17

    If it's the generation of random you want to avoid, rather than the loop, you can do the following:

    1. Generate a pair of random floating point coordinates in [0,1]
    2. Scale the coordinates to give a point in the outer rectangle.
    3. If your point is outside the inner rectangle, return it
    4. Rescale to map the inner rectangle to the outer rectangle
    5. Goto step 3

    This will work best if the inner rectangle is small as compared to the outer rectangle. And it should probably be limited to only going through the loop some maximum number of times before generating new random and trying again.

    0 讨论(0)
  • 2021-02-18 23:20
    1. Partition the box into a set of sub-boxes.
    2. Among the valid sub-boxes, choose which one to place your point in with probability proportional to their areas
    3. Pick a random point uniformly at random from within the chosen sub-box.

    This will generate samples from the uniform probability distribution on the valid region, based on the chain rule of conditional probability.

    0 讨论(0)
  • 2021-02-18 23:30

    I've already posted a different answer that I still like, as it is simple and clear, and not necessarily slow... at any rate it's not exactly what the OP asked for.

    I thought about it and I devised an algorithm for solving the OP's problem within their constraints:

    1. partition the screen in 9 rectangles around and comprising the "hole".
    2. consider the 8 rectangles ("tiles") around the central hole"
    3. for each tile, compute the origin (x, y), the height and the area in pixels
    4. compute the cumulative sum of the areas of the tiles, as well as the total area of the tiles
    5. for each extraction, choose a random number between 0 and the total area of the tiles (inclusive and exclusive)
    6. using the cumulative sums determine in which tile the random pixel lies
    7. using divmod determine the column and the row (dx, dy) in the tile
    8. using the origins of the tile in the screen coordinates, compute the random pixel in screen coordinates.

    To implement the ideas above, in which there is an initialization phase in which we compute static data and a phase in which we repeatedly use those data, the natural data structure is a class, and here it is my implementation

    from random import randrange
    
    class make_a_hole_in_the_screen():
    
        def __init__(self, screen, hole_orig, hole_sizes):
            xs, ys = screen
            x, y = hole_orig
            wx, wy = hole_sizes
            tiles = [(_y,_x*_y) for _x in [x,wx,xs-x-wx] for _y in [y,wy,ys-y-wy]]
            self.tiles = tiles[:4] + tiles[5:]
            self.pixels = [tile[1] for tile in self.tiles]
            self.total = sum(self.pixels)
            self.boundaries = [sum(self.pixels[:i+1]) for i in range(8)]
            self.x = [0,    0,    0,
                      x,          x,
                      x+wx, x+wx, x+wx]
            self.y = [0,    y,    y+wy,
                      0,          y+wy,
                      0,    y,    y+wy]
    
        def choose(self):
            n = randrange(self.total)
            for i, tile in enumerate(self.tiles):
                if n < self.boundaries[i]: break
            n1 = n - ([0]+self.boundaries)[i]
            dx, dy = divmod(n1,self.tiles[i][0])
            return self.x[i]+dx, self.y[i]+dy
    

    To test the correctness of the implementation, here it is a rough check that I run on python 2.7,

    drilled_screen = make_a_hole_in_the_screen((200,100),(30,50),(20,30))
    for i in range(1000000):
        x, y = drilled_screen.choose()
        if 30<=x<50 and 50<=y<80: print "***", x, y
        if x<0 or x>=200 or y<0 or y>=100: print "+++", x, y
    

    A possible optimization consists in using a bisection algorithm to find the relevant tile in place of the simpler linear search that I've implemented.

    0 讨论(0)
  • 2021-02-18 23:31

    This offers an O(1) approach in terms of both time and memory.

    Rationale

    The accepted answer along with some other answers seem to hinge on the necessity to generate lists of all possible coordinates, or recalculate until there is an acceptable solution. Both approaches take more time and memory than necessary.

    Note that depending on the requirements for uniformity of coordinate generation, there are different solutions as is shown below.

    First attempt

    My approach is to randomly choose only valid coordinates around the designated box (think left/right, top/bottom), then select at random which side to choose:

    import random
    # set bounding boxes    
    maxx=1000
    maxy=800
    blocked_box = [(500, 250), (100, 75)]
    # generate left/right, top/bottom and choose as you like
    def gen_rand_limit(p1, dim):
        x1, y1 = p1
        w, h = dim
        x2, y2 = x1 + w, y1 + h
        left = random.randrange(0, x1)
        right = random.randrange(x2+1, maxx-1)
        top = random.randrange(0, y1)
        bottom = random.randrange(y2, maxy-1)
        return random.choice([left, right]), random.choice([top, bottom])
    # check boundary conditions are met
    def check(x, y, p1, dim):
        x1, y1 = p1
        w, h = dim
        x2, y2 = x1 + w, y1 + h
        assert 0 <= x <= maxx, "0 <= x(%s) <= maxx(%s)" % (x, maxx)
        assert x1 > x or x2 < x, "x1(%s) > x(%s) or x2(%s) < x(%s)" % (x1, x, x2, x)
        assert 0 <= y <= maxy, "0 <= y(%s) <= maxy(%s)" %(y, maxy)
        assert y1 > y or y2 < y, "y1(%s) > y(%s) or y2(%s) < y(%s)" % (y1, y, y2, y)
    # sample
    points = []
    for i in xrange(1000):
        x,y = gen_rand_limit(*blocked_box)
        check(x, y, *blocked_box)
        points.append((x,y))
    

    Results

    Given the constraints as outlined in the OP, this actually produces random coordinates (blue) around the designated rectangle (red) as desired, however leaves out any of the valid points that are outside the rectangle but fall within the respective x or y dimensions of the rectangle:

    # visual proof via matplotlib
    import matplotlib
    from matplotlib import pyplot as plt
    from matplotlib.patches import Rectangle
    X,Y = zip(*points)
    fig = plt.figure()
    ax = plt.scatter(X, Y)
    p1 = blocked_box[0]
    w,h = blocked_box[1]
    rectangle = Rectangle(p1, w, h, fc='red', zorder=2)
    ax = plt.gca()
    plt.axis((0, maxx, 0, maxy))
    ax.add_patch(rectangle)
    

    Improved

    This is easily fixed by limiting only either x or y coordinates (note that check is no longer valid, comment to run this part):

    def gen_rand_limit(p1, dim):
        x1, y1 = p1
        w, h = dim
        x2, y2 = x1 + w, y1 + h
        # should we limit x or y?
        limitx = random.choice([0,1])
        limity = not limitx
        # generate x, y O(1)
        if limitx:
            left = random.randrange(0, x1)
            right = random.randrange(x2+1, maxx-1)
            x = random.choice([left, right])
            y = random.randrange(0, maxy)
        else:
            x = random.randrange(0, maxx)
            top = random.randrange(0, y1)
            bottom = random.randrange(y2, maxy-1)
            y = random.choice([top, bottom])
        return x, y 
    

    Adjusting the random bias

    As pointed out in the comments this solution suffers from a bias given to points outside the rows/columns of the rectangle. The following fixes that in principle by giving each coordinate the same probability:

    def gen_rand_limit(p1, dim):
        x1, y1 = p1Final solution -
        w, h = dim
        x2, y2 = x1 + w, y1 + h
        # generate x, y O(1)
        # --x
        left = random.randrange(0, x1)
        right = random.randrange(x2+1, maxx)
        withinx = random.randrange(x1, x2+1)
        # adjust probability of a point outside the box columns
        # a point outside has probability (1/(maxx-w)) v.s. a point inside has 1/w
        # the same is true for rows. adjupx/y adjust for this probability 
        adjpx = ((maxx - w)/w/2)
        x = random.choice([left, right] * adjpx + [withinx])
        # --y
        top = random.randrange(0, y1)
        bottom = random.randrange(y2+1, maxy)
        withiny = random.randrange(y1, y2+1)
        if x == left or x == right:
            adjpy = ((maxy- h)/h/2)
            y = random.choice([top, bottom] * adjpy + [withiny])
        else:
            y = random.choice([top, bottom])
        return x, y 
    

    The following plot has 10'000 points to illustrate the uniform placement of points (the points overlaying the box' border are due to point size).

    Disclaimer: Note that this plot places the red box in the very middle such thattop/bottom, left/right have the same probability among each other. The adjustment thus is relative to the blocking box, but not for all areas of the graph. A final solution requires to adjust the probabilities for each of these separately.

    Simpler solution, yet slightly modified problem

    It turns out that adjusting the probabilities for different areas of the coordinate system is quite tricky. After some thinking I came up with a slightly modified approach:

    Realizing that on any 2D coordinate system blocking out a rectangle divides the area into N sub-areas (N=8 in the case of the question) where a valid coordinate can be chosen. Looking at it this way, we can define the valid sub-areas as boxes of coordinates. Then we can choose a box at random and a coordinate at random from within that box:

    def gen_rand_limit(p1, dim):
        x1, y1 = p1
        w, h = dim
        x2, y2 = x1 + w, y1 + h
        # generate x, y O(1)
        boxes = (
           ((0,0),(x1,y1)),   ((x1,0),(x2,y1)),    ((x2,0),(maxx,y1)),
           ((0,y1),(x1,y2)),                       ((x2,y1),(maxx,y2)),
           ((0,y2),(x1,maxy)), ((x1,y2),(x2,maxy)), ((x2,y2),(maxx,maxy)),
        )
        box = boxes[random.randrange(len(boxes))]
        x = random.randrange(box[0][0], box[1][0])
        y = random.randrange(box[0][1], box[1][1])
        return x, y 
    

    Note this is not generalized as the blocked box may not be in the middle hence boxes would look different. As this results in each box chosen with the same probability, we get the same number of points in each box. Obviously the densitiy is higher in smaller boxes:

    If the requirement is to generate a uniform distribution among all possible coordinates, the solution is to calculate boxes such that each box is about the same size as the blocking box. YMMV

    0 讨论(0)
提交回复
热议问题