The dynamic environment - abraker95/ultimate_osu_analyzer GitHub Wiki

The replay analyzer can do much much more than simply load maps, replays and show you various things. It can do much more than allow you to write code in a built-in console. Often times when testing a formula various fixes to it need to be made. However, to do that you would need to restart the program for changes to code to apply. The analyzer is made such that you can apply your code changes without restarting it, and that opens up new possibilities!

We look at 3 things in this tutorial:

  • How this on-the-fly reloading works
  • Making a simple algorithm to detect tapping speed
  • External scripts that can be loaded from the embedded console

How does it work

The embedded console is basically a Jupyter Notebook widget that has the following two magic commands set load_ext autoreload and autoreload 2. They are really called "magic commands". More info regarding that can be found here

The push_vars function allows to push variables part of the analyzer's code into the embedded console. So basically it allows the analyzer to access its insides - if that makes sense. A bunch of variables are pushed in run.py when the analyzer first starts - see update_gui function for what exactly gets pushed. If you need to access analyzer internals that are not available, this how you would make them available.

As an example, one of the variables pushed is timeline, which is the timeline displayed on the bottom. Other than being able to access a bunch of things pertaining to the Timeline object that aren't really necessary, you can also set the current time of the beatmap displayed through it. Go ahead and load a beatmap, then adjust the time with timeline.timeline_marker.setPos(1000) where 1000 is time in ms. You can create a little animation like this with the provided tick function:

for i in range(1000, 2000):
    timeline.timeline_marker.setPos(i)
    tick()

Do note the analyzer will lock up if you don't use the tick() function. You won't be able to use the console while the above code is running, and using multi-threading to bypass that is not recommended. Using threads to drive gui specific operations is unsupported and will cause unintended side effects.

Creating and adjusting algorithms on the fly

All of the code pertaining to beatmap and replay analysis is located in analysis folder. For this exercise we will create a new algorithm that reads beatmap data, calculates how fast the player needs to tap the note, and converts that into a strain metric for pp calculation.

We will put the function for that in analysis/std/map_metrics.py. There are a bunch of functions there already, but don't let that intimidate you. Feel free to remove those if you want as they are provided for convenience purposes. This applies to everything else in the analysis folder.

Let's start by getting the start times of the hitobjects and calculating the time intervals between each one.

@staticmethod
def speed_difficulty(hitobject_data=[]):
    t = StdMapData.start_times(hitobject_data)
    dt = 1000/np.diff(t)
    
    difficulty = # ???
    return t[:-1], difficulty

We return t[1:] so we can later graph it to see what it looks like through time. Since we took the delta (difference between each value), there is one less value in the dt array which would be used to calculate difficulty. Let's say speed difficulty depends on how long the speed is sustained for, basically speed strains. Let's say the last 10 notes determine the speed difficulty. So we have:

difficulty = np.convolve(dt, np.ones(10), 'same')

Despite that we just added this function in the file, we can already access it in the embedded console! Let's see what it looks like. In the embedded console do:

map_data = StdMapData.get_map_data(get_beatmap().hitobjects)
speed_diff = StdMapMetrics.speed_difficulty(map_data)

Now we will use pyqtgraph to visualize the data. We will import pyqtgraph, set up a window, and draw the plot:

import pyqtgraph

# Set up the window
win = pyqtgraph.GraphicsWindow(title='Graph')
win.resize(1000, 600)

# Plot and show the graph
hit_offset_plot = win.addPlot(title='Speed Difficulty').plot()
hit_offset_plot.setData(x=speed_diff[0], y=speed_diff[1], pen='y')
win.show()

Try changing the number of hitobjects that get speed depends on, changing np.ones(10) to np.ones(20), for example. Despite you doing this, the graph has not changed. That is because the data doesn't calculate on its own. You will have to run the speed_difficulty function again and set the data:

speed_diff = speed_difficulty(map_data)
hit_offset_plot.setData(x=speed_diff[0], y=speed_diff[1], pen='y')

This applies for every time you make a change in code.

Even though you are requiring to run the calculation over again, it still beats restarting the analyzer for every big or small code change. Do note not everything you change in the code will get applied, just the stuff that are pushed into the embedded console via push_vars function mentioned earlier.

External scripts

The embedded console is convenient for interactive scripting, but it can get quite messy when the scripts get bigger. Consider the script from the "Analyzing the data" in the "Analyzing beatmaps and replays" tutorial. While a moderate script, it can get quite bigger.

The CmdUtils.run_script function is provided to solve just that. The provided scripts are found in the scripts folder. Let's run an example script named test_script.py. To do that:

CmdUtils.run_script('scripts/test_script.py', globals(), locals())

Note that globals() and locals() are passed parameters. Without those the script wouldn't have access to all the things the embedded console has access to. So after running the script not much happens, but we now have access to the TestScript class. If you look into scripts/test_script.py it's a class that prints "hello world" when initialized and returns and addition of to parameters when run using the run function. So now in the embedded console we can do:

ret = TestScript().run(3, 6)

and it will display hello world

It is recommended to create your script as a class to lower chances there might be conflicts in variable names. If there are, you may accidentally overwrite or use an unintended variable. Much like the speed difficulty function we created earlier, you will have to reload the script via CmdUtils.run_script and create a new class instance every time you make a change to it.