forum

[Coding] mapparse for osbpy

posted
Total Posts
1
Topic Starter
Flowey
Hi all.

I've been using osbpy for a while now, and I've written an add-on script that makes it really easy to get the colors and positions of the hitobjects in the map. This can let you do a lot of cool things in your storyboard. Here it is:

mapparse.py
from math import *
from bisect import *
import numpy as np
import os
import scipy

sliderMultiplier = 0
beatLength = 0

timingSections = []
beatmapParseState = 0
timingSectionId = 0

mapCombo_R = []
mapCombo_G = []
mapCombo_B = []

numComboColors = 0
mapComboColors = {0:0}
comboColor = 0
firstCombo = True

mapHitObjects = {}
lastHitObjectEnd = 0

mapComboColors_Keys = []
mapHitObjects_Keys = []

def parseBeatmap(songFolderPath, osuFileName):

global sliderMultiplier
global beatLength

global timingSections
global beatmapParseState
global timingSectionId

global mapCombo_R
global mapCombo_G
global mapCombo_B

global numComboColors
global mapComboColors
global comboColor
global firstCombo

global newComboTimes

global mapHitObjects
global lastHitObjectEnd

global mapComboColors_Keys
global mapHitObjects_Keys

beatmap = open(songFolderPath + osuFileName, encoding="utf-8").readlines()
for line in beatmap:
if beatmapParseState == 0:
if "SliderMultiplier" in line:
sliderMultiplier = float(line.split(":")[1])
elif line == "[TimingPoints]\n":
beatmapParseState = 1
elif line == "[Colours]\n":
beatmapParseState = 2
elif line == "[HitObjects]\n":
beatmapParseState = 3
elif beatmapParseState == 1: #Parse Timing Sections
if line == "\n":
beatmapParseState = 0
else:
if beatLength == 0:
beatLength = float(line.split(",")[1])
timingSections.append(line.split(","))
elif beatmapParseState == 2: #Parse Combo Colors
if line == "\n":
beatmapParseState = 0
else:
numComboColors += 1
rgb = ("".join(line.split())).split(":")[1].split(",")
mapCombo_R.append(int(rgb[0]))
mapCombo_G.append(int(rgb[1]))
mapCombo_B.append(int(rgb[2]))
elif beatmapParseState == 3: #Parse Hit Objects
hitObject = line.split(",")
currentTime = int(hitObject[2]) #Get current time in beatmap

#Update timing section as needed
currentTimingSection = timingSections[timingSectionId]
if timingSectionId < len(timingSections) - 1:
nextTimingSection = timingSections[timingSectionId + 1]

if currentTime >= int(nextTimingSection[0]):
timingSectionId += 1
currentTimingSection = timingSections[timingSectionId]

currentSliderMultiplier = 1
if float(currentTimingSection[1]) < 0:
currentSliderMultiplier = 100 / -float(currentTimingSection[1])

objectType = int(hitObject[3])

#Populate combo color data structure
if firstCombo:
firstCombo = False
elif (objectType & 8) == 0 and (objectType & 4) != 0:
comboIncrease = (objectType >> 4) + 1

comboColor += comboIncrease
comboColor %= numComboColors

mapComboColors[currentTime] = comboColor

objectX = int(hitObject[0])
objectY = int(hitObject[1])
if objectType & 1 != 0: # Circle
mapHitObjects[currentTime] = (0, objectX, objectY)
lastHitObjectEnd = currentTime
elif objectType & 2 != 0: # Slider
sliderRepeats = int(hitObject[6])
sliderPxLength = float(hitObject[7])

sliderDuration = sliderPxLength * beatLength / (sliderMultiplier * 100 * currentSliderMultiplier)

mapHitObjects[currentTime] = (1, objectX, objectY, hitObject[5].split("|"), sliderRepeats, sliderPxLength, currentSliderMultiplier, sliderDuration)

lastHitObjectEnd = currentTime + sliderRepeats * sliderDuration
elif objectType & 8 != 0: # Spinner
mapHitObjects[currentTime] = (2, 320, 240)

mapHitObjects[int(lastHitObjectEnd + 4 * beatLength)] = (0, 256, 192) # Create a dummy circle to handle edge case

#Postprocessing Data Structures
mapComboColors_Keys = sorted(mapComboColors.keys())
mapHitObjects_Keys = sorted(mapHitObjects.keys())


#Combo color functions
def getCurrentComboColorId(time):
i = bisect(mapComboColors_Keys, time)
if i == len(mapComboColors_Keys):
i = len(mapComboColors_Keys) - 1
return mapComboColors[mapComboColors_Keys[i]]

def getCurrentComboColor(time):
c = getCurrentComboColorId(time)
return mapCombo_R[c], mapCombo_G[c], mapCombo_B[c]

def isNewCombo(time):
return time in mapComboColors_Keys

#Getter functions
def getNewComboTimes():
return mapComboColors_Keys

def getHitObjectTimes():
return mapHitObjects_Keys

def getHitObject(time):
return mapHitObjects[time]


#Helper for getting map position
def osu2SbCoord(x, y):
x2 = x + 64
y2 = y + 58 # +72 for editor alignment
return x2, y2

#For memoizing computation
bezierDistanceTable = {}
bezierApproxTable = {}
currentPositionTable = {}

def getCurrentPosition(time):
if time in currentPositionTable.keys():
return currentPositionTable[time]
else:
i = bisect(mapHitObjects_Keys, time)
if i < len(mapHitObjects_Keys):
prevHitObjectTime = mapHitObjects_Keys[i-1]
nextHitObjectTime = mapHitObjects_Keys[i]

prevHitObject = mapHitObjects[prevHitObjectTime]
nextHitObject = mapHitObjects[nextHitObjectTime]

prevHitObjectType = prevHitObject[0]
nextHitObjectType = nextHitObject[0]


if prevHitObjectType == 0: #hit circle
t = (time - prevHitObjectTime)/(nextHitObjectTime - prevHitObjectTime)
x = prevHitObject[1] * (1-t) + nextHitObject[1] * t
y = prevHitObject[2] * (1-t) + nextHitObject[2] * t

currentPositionTable[time] = osu2SbCoord(x, y)
return osu2SbCoord(x, y)
elif prevHitObjectType == 1: #slider
sliderInfo = prevHitObject[3]
sliderPointsStr = sliderInfo[1:]
sliderPointsX = [prevHitObject[1]]
sliderPointsY = [prevHitObject[2]]

sliderPointsX += map(lambda x : int(x.split(":")[0]), sliderPointsStr)
sliderPointsY += map(lambda x : int(x.split(":")[1]), sliderPointsStr)

sliderRepeats = prevHitObject[4]
sliderPxLength = prevHitObject[5]
sliderVelocity = prevHitObject[6]
sliderDuration = prevHitObject[7]

relativeTime = time - prevHitObjectTime

repeatCount = int(relativeTime/sliderDuration)

endX = 0
endY = 0

if len(sliderPointsX) == 2: #Line slider
x1 = sliderPointsX[0]
x2 = sliderPointsX[1]
y1 = sliderPointsY[0]
y2 = sliderPointsY[1]

dx = x2 - x1
dy = y2 - y1

magnitude = sqrt(dx * dx + dy * dy)
dxNorm = dx / magnitude
dyNorm = dy / magnitude

endX = x1 + dxNorm * sliderPxLength
endY = y1 + dyNorm * sliderPxLength

if repeatCount < sliderRepeats:
t = (relativeTime % sliderDuration) / sliderDuration

t = t if repeatCount % 2 == 0 else 1-t

x = x1 * (1-t) + endX * t
y = y1 * (1-t) + endY * t

currentPositionTable[time] = osu2SbCoord(x, y)
return osu2SbCoord(x, y)
elif len(sliderPointsX) == 3: #Circle slider
x1 = sliderPointsX[0]
x2 = sliderPointsX[1]
x3 = sliderPointsX[2]

y1 = sliderPointsY[0]
y2 = sliderPointsY[1]
y3 = sliderPointsY[2]

x_a = (x1 + x2) / 2
y_a = (y1 + y2) / 2

x_b = (x2 + x3) / 2
y_b = (y2 + y3) / 2

dx_a = y1 - y2
dy_a = x2 - x1
dx_b = y2 - y3
dy_b = x3 - x2

crossProd = dx_a * dy_b - dy_a * dx_b

dx_c = x_b - x_a
dy_c = y_b - y_a

crossProd2 = dx_c * dy_b - dy_c * dx_b

if crossProd == 0: #The 3 points are colinear... the middle point should be removed from the slider
currentPositionTable[time] = (0, 0)
return 0, 0
elif crossProd2 == 0: #Not handling this case (Unrankable slider anyways)
currentPositionTable[time] = (0, 0)
return 0, 0
else:
t_a = crossProd2 / crossProd
circleCenterX = x_a + t_a * dx_a
circleCenterY = y_a + t_a * dy_a

rx = x1 - circleCenterX
ry = y1 - circleCenterY

radius = sqrt(rx * rx + ry * ry)

linesideTest = (x2 - x1) * (y3 - y1) - (y2 - y1) * (x3 - x1)

radians = sliderPxLength / radius

startAngle = atan2(ry, rx)
endAngle = startAngle + (-1 if linesideTest < 0 else 1) * radians

endX = circleCenterX + radius * cos(endAngle)
endY = circleCenterY + radius * sin(endAngle)

if repeatCount < sliderRepeats:
t = (relativeTime % sliderDuration) / sliderDuration

t = t if repeatCount % 2 == 0 else 1-t

ang_t = startAngle * (1-t) + endAngle * t

x = circleCenterX + radius * cos(ang_t)
y = circleCenterY + radius * sin(ang_t)

currentPositionTable[time] = osu2SbCoord(x, y)
return osu2SbCoord(x, y)
else: #Bezier, possibly piecewise, may contain lines
def bezier(points, t):
p = list(map(lambda x: np.array(x), points))
deg = len(points) - 1

result = np.array([0, 0])

for j in range(deg + 1):
b = scipy.special.binom(deg, j)
t2 = ((1-t) ** (deg - j)) * (t ** j)
result = result + b * t2 * p[j]

return result

def bezierDist(points, res = 1000):
if(len(points) == 2):
p1 = np.array(points[0])
p2 = np.array(points[1])

dp = p1 - p2

return sqrt(dp.dot(dp))
else:
pointsTuple = tuple(points)
if pointsTuple in bezierDistanceTable.keys():
return bezierDistanceTable[pointsTuple]
else:
dist = 0
for j in range(res):
t1 = j/res
t2 = (j+1)/res
p1 = bezier(points, t1)
p2 = bezier(points, t2)

dp = p1 - p2

dist += sqrt(dp.dot(dp))

# Lazy way of memoizing
bezierDistanceTable[pointsTuple] = dist
return dist

def bezier_arclengthP(points, relativeDist, res = 1000, partDist = 0):
if (len(points) == 2):
p1 = np.array(points[0])
p2 = np.array(points[1])

dp = p1 - p2

t2 = relativeDist / sqrt(dp.dot(dp))

return p1 * (1 - t2) + p2 * t2
else:
approximationPoints = [bezier(points, 0)]
dists = [0]

pointsTuple = tuple(points)
if pointsTuple in bezierApproxTable.keys():
approximationPoints, dists, totalDists = bezierApproxTable[pointsTuple]
else:
for j in range(res):
t1 = j/res
t2 = (j+1)/res
p1 = bezier(points, t1)
p2 = bezier(points, t2)

approximationPoints.append(p2)

dp = p1 - p2
dists.append(sqrt(dp.dot(dp)))

totalDists = np.cumsum(dists)

bezierApproxTable[pointsTuple] = (approximationPoints, dists, totalDists)

idx = bisect(totalDists, relativeDist) - 1

if (relativeDist > totalDists[-1]):
print("Report slider at time", prevHitObjectTime)
idx = len(approximationPoints) - 2

approxRelativeDist = relativeDist - totalDists[idx]

t = approxRelativeDist / dists[idx + 1]

p1 = approximationPoints[idx]
p2 = approximationPoints[idx + 1]

return p1 * (1 - t) + p2 * t

points = list(zip(sliderPointsX, sliderPointsY))

parts = []
currentPart = []


for p in range(len(points)):
if p == 0:
currentPart.append(points[p])
elif p == len(points) - 1:
currentPart.append(points[p])
parts.append(currentPart)
else:
currentPoint = points[p]
prevPoint = points[p-1]

if prevPoint == currentPoint:
parts.append(currentPart)
currentPart = [currentPoint]
else:
currentPart.append(currentPoint)

parts = list(filter(lambda x : len(x) > 1, parts))

partLengths = []
for part in parts:
partLengths.append(bezierDist(part))

totalLengths = np.cumsum(partLengths)

relativeEndDist = sliderPxLength
if len(parts) > 1:
relativeEndDist -= totalLengths[-2]

endX, endY = bezier_arclengthP(parts[-1], relativeEndDist)

if repeatCount < sliderRepeats:
t = (relativeTime % sliderDuration) / sliderDuration

t = t if repeatCount % 2 == 0 else 1-t

currentSliderDistance = t * sliderPxLength
currentSliderPartId = bisect(totalLengths, currentSliderDistance)
currentSliderPart = parts[currentSliderPartId]

relativePartDist = currentSliderDistance
if currentSliderPartId > 0:
relativePartDist -= totalLengths[currentSliderPartId - 1]

t2 = relativePartDist / partLengths[currentSliderPartId]
x, y = tuple(bezier_arclengthP(currentSliderPart, relativePartDist, partDist = partLengths[currentSliderPartId]))

currentPositionTable[time] = osu2SbCoord(x, y)
return osu2SbCoord(x, y)

#after slider ends, but before next hitobject
sliderEndTime = prevHitObjectTime + sliderDuration
t = (time - sliderEndTime) / (nextHitObjectTime - sliderEndTime)

if sliderRepeats % 2 == 0:
x = prevHitObject[1] * (1-t) + nextHitObject[1] * t
y = prevHitObject[2] * (1-t) + nextHitObject[2] * t

currentPositionTable[time] = osu2SbCoord(x, y)
return osu2SbCoord(x, y)
else:
x = endX * (1-t) + nextHitObject[1] * t
y = endY * (1-t) + nextHitObject[2] * t

currentPositionTable[time] = osu2SbCoord(x, y)
return osu2SbCoord(x, y)

else:

currentPositionTable[time] = osu2SbCoord(256, 192)
return osu2SbCoord(256, 192)
else:
#print("Last Object Reached")
currentPositionTable[time] = osu2SbCoord(256, 192)
return osu2SbCoord(256, 192)

Usage:
To use, just copy the above code and save it in a file named "mapparse.py". Then place it in the same folder as your "osbpy.py" file.

Example:
Here is a sample storyboard, for the "Storyboard Test" difficulty of this map. This is just a simple example for demonstrating how to use this script. As I finish more storyboards using this script, I will link to them in this section.
storyboard.py
from osbpy import *
from mapparse import *

# The folder that contains the beatmap - Change as needed
songFolderPath = "C:/Path/To/Song/Folder/"

# The name of the .osu file - Change as needed
osuFileName = "toby fox - MEGALOVANIA (Nephroid) [Storyboard Test].osu"

# The name of the .osb file - Change as needed
osbFileName = "toby fox - MEGALOVANIA (Nephroid).osb"

parseBeatmap(songFolderPath, osuFileName)


# Milliseconds per beat
beat = 250

# Sample SB
testSprite = osbject("SB/cross.png", "Background", "Centre", 320, 240)

for t in getHitObjectTimes():
r, g, b = getCurrentComboColor(t)
testSprite.color(0, t, t, r, g, b, r, g, b)

for t in range(0, 22000, beat//16):
x1, y1 = getCurrentPosition(t)
x2, y2 = getCurrentPosition(t + beat//16)

testSprite.move(0, t, t + beat//16, x1, y1, x2, y2)

osbject.end(songFolderPath + osbFileName)

Function Documentation:
getCurrentComboColor(t)
  1. Returns the current combo color as a (r, g, b) tuple.
  2. t - current time in milliseconds.
  3. Example Usage:
    r, g, b = getCurrentComboColor(t)

getCurrentComboColorId(t)
  1. Returns a number that corresponds to the current combo color. Starts from 0.
  2. This is useful if you have an array of colors that are different from the combo colors, but you want each of your colors to correspond to a combo color.
  3. t - current time in milliseconds.
  4. Example Usage:
    id = getCurrentComboColorId(t)
    r, g, b = myColorArray[id]

isNewCombo(t)
  1. Returns true if there is a new combo at the current time, otherwise false.
  2. t - current time in milliseconds.
  3. Example Usage:
    if isNewCombo(t):
    addParticlesToSB(t)

getNewComboTimes()
  1. Returns a list containing all of the times that have a new combo, sorted from first to last.
  2. Example Usage:
    for t in getNewComboTimes():
    addParticlesToSB(t)

getHitObjectTimes()
  1. Returns a list containing all of the times when a hitobject starts, sorted from first to last.
  2. Example Usage:
    for t in getHitObjectTimes():
    addParticlesToSB(t)

getHitObject(t)
  1. Returns the hitobject that starts at some time, t.
  2. This will break if no hitobjects start at t, so you should use this with getHitObjectTimes().
  3. t - current time in milliseconds.
  4. See the next section for hitobject documentation.
  5. Example Usage:
    hitObject = getHitObject(t)

getCurrentPosition(t)
  1. Returns the current position of the beatmap as a (x, y) tuple.
  2. t - current time in milliseconds.
  3. See the next section for hitobject documentation.
  4. Example Usage:
    x, y = getCurrentPosition(t)

HitObject Documentation
A hitobject is represented by a tuple, and the first element of the tuple will tell you what type of hitobject it is:
  1. 0 - circle
  2. 1 - slider
  3. 2 - spinner
If the hitobject is a circle, then the tuple will be as follows:
  1. (type, x, y)
  2. type - This will be 0
  3. x - The x coordinate of the hit object.
  4. y - The y coordinate of the hit object.
  5. Note: x and y are game coordinates, which is different from SB coordinates. If you need the position of the current hitobject, you should use getCurrentPosition(t) instead.
If the hitobject is a slider, then the tuple will be as follows:
  1. (type, x, y, pnts, numRepeats, sliderPxLength, multiplier, duration)
  2. type - This will be 1
  3. x - The x coordinate of the first control point.
  4. y - The y coordinate of the first control point.
  5. pnts - A list containing the rest of the control points in the slider, as strings of the form "x:y".
  6. numRepeats - The number of times this slider repeats.
  7. sliderPxLength - The length of the slider in osupixels.
  8. multiplier - The slider velocity multiplier of the timing section that this slider is in.
  9. duration - The amount of milliseconds that it takes to go through the slider once. Multiply this by the number of repeats to get the total duration of the slider.
If the hitobject is a spinner, the tuple will be (2, 320, 240).

Known Issues
There are quite a few bugs in this script, but they shouldn't be too problematic for the most part. Since osu! is becoming open source soon, I'd rather wait for that to happen and add hooks to the SB system that give storyboards direct access to the beatmap information, making this script obsolete. But for now, here is a (probably incomplete) list of bugs in this script:
  1. This will crash if there are no custom combo colors set.
  2. getCurrentComboColorId and getCurrentComboColor do not work properly with the last combo in the map. (It will give you the previous combo color instead.)
  3. getCurrentPosition will not work properly with certain sliders: sliders that start/end with red control points, sliders with 3 consecutive points that are colinear, etc. In most cases, you can change the sliders to eliminate these edge cases.
  4. getCurrentPosition will not work properly with spinners.
Please sign in to reply.

New reply