Start version control like a real programmer
This commit is contained in:
+171
@@ -0,0 +1,171 @@
|
||||
vite-project/public/output_video.mp4
|
||||
clicked_points.json
|
||||
annotated_drs.json
|
||||
|
||||
# Byte-compiled / optimized / DLL files
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
*$py.class
|
||||
|
||||
DanceDashLaunchHelper/bin/
|
||||
DanceDashLaunchHelper/.idea/
|
||||
DanceDashLaunchHelper/obj/
|
||||
|
||||
|
||||
.idea
|
||||
|
||||
# C extensions
|
||||
*.so
|
||||
|
||||
# Distribution / packaging
|
||||
.Python
|
||||
build/
|
||||
develop-eggs/
|
||||
dist/
|
||||
downloads/
|
||||
eggs/
|
||||
.eggs/
|
||||
lib/
|
||||
lib64/
|
||||
parts/
|
||||
sdist/
|
||||
var/
|
||||
wheels/
|
||||
share/python-wheels/
|
||||
*.egg-info/
|
||||
.installed.cfg
|
||||
*.egg
|
||||
MANIFEST
|
||||
|
||||
# PyInstaller
|
||||
# Usually these files are written by a python script from a template
|
||||
# before PyInstaller builds the exe, so as to inject date/other infos into it.
|
||||
*.manifest
|
||||
*.spec
|
||||
|
||||
# Installer logs
|
||||
pip-log.txt
|
||||
pip-delete-this-directory.txt
|
||||
|
||||
# Unit test / coverage reports
|
||||
htmlcov/
|
||||
.tox/
|
||||
.nox/
|
||||
.coverage
|
||||
.coverage.*
|
||||
.cache
|
||||
nosetests.xml
|
||||
coverage.xml
|
||||
*.cover
|
||||
*.py,cover
|
||||
.hypothesis/
|
||||
.pytest_cache/
|
||||
cover/
|
||||
|
||||
# Translations
|
||||
*.mo
|
||||
*.pot
|
||||
|
||||
# Django stuff:
|
||||
*.log
|
||||
local_settings.py
|
||||
db.sqlite3
|
||||
db.sqlite3-journal
|
||||
|
||||
# Flask stuff:
|
||||
instance/
|
||||
.webassets-cache
|
||||
|
||||
# Scrapy stuff:
|
||||
.scrapy
|
||||
|
||||
# Sphinx documentation
|
||||
docs/_build/
|
||||
|
||||
# PyBuilder
|
||||
.pybuilder/
|
||||
target/
|
||||
|
||||
# Jupyter Notebook
|
||||
.ipynb_checkpoints
|
||||
|
||||
# IPython
|
||||
profile_default/
|
||||
ipython_config.py
|
||||
|
||||
# pyenv
|
||||
# For a library or package, you might want to ignore these files since the code is
|
||||
# intended to run in multiple environments; otherwise, check them in:
|
||||
# .python-version
|
||||
|
||||
# pipenv
|
||||
# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control.
|
||||
# However, in case of collaboration, if having platform-specific dependencies or dependencies
|
||||
# having no cross-platform support, pipenv may install dependencies that don't work, or not
|
||||
# install all needed dependencies.
|
||||
#Pipfile.lock
|
||||
|
||||
# poetry
|
||||
# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control.
|
||||
# This is especially recommended for binary packages to ensure reproducibility, and is more
|
||||
# commonly ignored for libraries.
|
||||
# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control
|
||||
#poetry.lock
|
||||
|
||||
# pdm
|
||||
# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control.
|
||||
#pdm.lock
|
||||
# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it
|
||||
# in version control.
|
||||
# https://pdm.fming.dev/#use-with-ide
|
||||
.pdm.toml
|
||||
|
||||
# PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm
|
||||
__pypackages__/
|
||||
|
||||
# Celery stuff
|
||||
celerybeat-schedule
|
||||
celerybeat.pid
|
||||
|
||||
# SageMath parsed files
|
||||
*.sage.py
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
ENV/
|
||||
env.bak/
|
||||
venv.bak/
|
||||
|
||||
# Spyder project settings
|
||||
.spyderproject
|
||||
.spyproject
|
||||
|
||||
# Rope project settings
|
||||
.ropeproject
|
||||
|
||||
# mkdocs documentation
|
||||
/site
|
||||
|
||||
# mypy
|
||||
.mypy_cache/
|
||||
.dmypy.json
|
||||
dmypy.json
|
||||
|
||||
# Pyre type checker
|
||||
.pyre/
|
||||
|
||||
# pytype static type analyzer
|
||||
.pytype/
|
||||
|
||||
# Cython debug symbols
|
||||
cython_debug/
|
||||
|
||||
# PyCharm
|
||||
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
|
||||
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
|
||||
# and can be added to the global gitignore or merged into this file. For a more nuclear
|
||||
# option (not recommended) you can uncomment the following to ignore the entire idea folder.
|
||||
#.idea/
|
||||
@@ -0,0 +1,34 @@
|
||||
repos:
|
||||
- repo: https://github.com/pre-commit/pre-commit-hooks
|
||||
rev: v4.4.0
|
||||
hooks:
|
||||
- id: trailing-whitespace
|
||||
- id: end-of-file-fixer
|
||||
- id: check-yaml
|
||||
- id: debug-statements
|
||||
- id: double-quote-string-fixer
|
||||
- repo: https://github.com/asottile/reorder_python_imports
|
||||
rev: v3.9.0
|
||||
hooks:
|
||||
- id: reorder-python-imports
|
||||
args: [--py37-plus, --add-import, "from __future__ import annotations"]
|
||||
- repo: https://github.com/asottile/add-trailing-comma
|
||||
rev: v2.4.0
|
||||
hooks:
|
||||
- id: add-trailing-comma
|
||||
args: [--py36-plus]
|
||||
- repo: https://github.com/asottile/pyupgrade
|
||||
rev: v3.3.2
|
||||
hooks:
|
||||
- id: pyupgrade
|
||||
args: [--py37-plus]
|
||||
- repo: https://github.com/pre-commit/mirrors-autopep8
|
||||
rev: v2.0.2
|
||||
hooks:
|
||||
- id: autopep8
|
||||
- repo: https://github.com/PyCQA/flake8
|
||||
rev: 6.0.0
|
||||
hooks:
|
||||
- id: flake8
|
||||
args:
|
||||
- --max-line-length=120
|
||||
@@ -0,0 +1,36 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net46</TargetFramework>
|
||||
<AssemblyName>DanceDashLaunchHelper</AssemblyName>
|
||||
<Description>Simple plugin to assist launching Dance Dash without VR</Description>
|
||||
<Version>1.0.0</Version>
|
||||
<AllowUnsafeBlocks>true</AllowUnsafeBlocks>
|
||||
<LangVersion>latest</LangVersion>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="BepInEx.Analyzers" Version="1.*" PrivateAssets="all" />
|
||||
<PackageReference Include="BepInEx.Core" Version="5.*" />
|
||||
<PackageReference Include="BepInEx.PluginInfoProps" Version="1.*" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup Condition="'$(TargetFramework.TrimEnd(`0123456789`))' == 'net'">
|
||||
<PackageReference Include="Microsoft.NETFramework.ReferenceAssemblies" Version="1.0.2" PrivateAssets="all" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Reference Include="DDVR">
|
||||
<HintPath>..\Dance Dash_Data\Managed\DDVR.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine">
|
||||
<HintPath>..\Dance Dash_Data\Managed\UnityEngine.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.CoreModule">
|
||||
<HintPath>..\Dance Dash_Data\Managed\UnityEngine.CoreModule.dll</HintPath>
|
||||
</Reference>
|
||||
<Reference Include="UnityEngine.UI">
|
||||
<HintPath>..\Dance Dash_Data\Managed\UnityEngine.UI.dll</HintPath>
|
||||
</Reference>
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
@@ -0,0 +1,4 @@
|
||||
<wpf:ResourceDictionary xml:space="preserve" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:s="clr-namespace:System;assembly=mscorlib" xmlns:ss="urn:shemas-jetbrains-com:settings-storage-xaml" xmlns:wpf="http://schemas.microsoft.com/winfx/2006/xaml/presentation">
|
||||
<s:Boolean x:Key="/Default/AddReferences/RecentPaths/=D_003A_005Cgames_005Csteamapps_005Ccommon_005CDance_0020Dash_005CDance_0020Dash_005FData_005CManaged_005CDDVR_002Edll/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:Boolean x:Key="/Default/AddReferences/RecentPaths/=D_003A_005Cgames_005Csteamapps_005Ccommon_005CDance_0020Dash_005CDance_0020Dash_005FData_005CManaged_005CUnityEngine_002Edll/@EntryIndexedValue">True</s:Boolean>
|
||||
<s:String x:Key="/Default/Environment/AssemblyExplorer/XmlDocument/@EntryValue"><AssemblyExplorer /></s:String></wpf:ResourceDictionary>
|
||||
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<configuration>
|
||||
<packageSources>
|
||||
<add key="BepInEx" value="https://nuget.bepinex.dev/v3/index.json" />
|
||||
</packageSources>
|
||||
</configuration>
|
||||
@@ -0,0 +1,139 @@
|
||||
using BepInEx;
|
||||
using BepInEx.Logging;
|
||||
using Controller;
|
||||
using CustomEventHandler;
|
||||
using HarmonyLib;
|
||||
using UI.Panel;
|
||||
using UI.SelectPanel;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
|
||||
namespace DanceDashLaunchHelper
|
||||
{
|
||||
[BepInPlugin(PluginInfo.PLUGIN_GUID, PluginInfo.PLUGIN_NAME, PluginInfo.PLUGIN_VERSION)]
|
||||
public class Plugin : BaseUnityPlugin
|
||||
{
|
||||
private void Awake()
|
||||
{
|
||||
Logger.LogInfo($"Plugin {PluginInfo.PLUGIN_GUID} is loaded!");
|
||||
var harmony = new Harmony(PluginInfo.PLUGIN_GUID);
|
||||
harmony.PatchAll();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
// when we first start the game, we set the ready button to be interactable & click it programmatically
|
||||
[HarmonyPatch(typeof(ControllerSwitchUI), "Start")]
|
||||
class ControllerSwitchUI_Start_Patch
|
||||
{
|
||||
static bool Prefix(ControllerSwitchUI __instance)
|
||||
{
|
||||
Button btn = (Button)Traverse.Create(__instance).Field("readyBTN").GetValue();
|
||||
Traverse.Create(btn).Field("m_Interactable").SetValue(true);
|
||||
|
||||
GameObject hintText = (GameObject)Traverse.Create(__instance).Field("hintTextObj").GetValue();
|
||||
hintText.SetActive(false);
|
||||
|
||||
btn.onClick.Invoke();
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// we trigger PanelGameSetting.OnToggleHpChange with true to force infinite hp (dont wanna die)
|
||||
[HarmonyPatch(typeof(PanelGameSetting), "Awake")]
|
||||
class PanelGameSetting_Awake_Patch
|
||||
{
|
||||
static void Postfix(PanelGameSetting __instance)
|
||||
{
|
||||
Traverse.Create(__instance).Method("OnToggleHpChange", true).GetValue();
|
||||
}
|
||||
}
|
||||
|
||||
// this patch is bad, it hijacks init and toggles the albums and invokes them as true, happens to
|
||||
// toggle on the last album (custom songs) purely based on the order of execution, but again, this sucks
|
||||
[HarmonyPatch(typeof(VolListItem), "Init")]
|
||||
class VolListItem_Init_Patch
|
||||
{
|
||||
private static ManualLogSource logger = BepInEx.Logging.Logger.CreateLogSource("VolListItem_Init_Patch");
|
||||
|
||||
static void Postfix(VolListItem __instance)
|
||||
{
|
||||
logger.LogInfo($"Invoking on {__instance} inside VolListItem.Init");
|
||||
__instance.Invoke(true);
|
||||
}
|
||||
}
|
||||
|
||||
// hijacks the ost selection happening as a side effect of VolListItem_Init_Patch, and clicks the play button
|
||||
[HarmonyPatch(typeof(PanelSelect), "OnSelectOst")]
|
||||
class PanelSelect_OnSelectOst_Patch
|
||||
{
|
||||
static void Postfix(PanelSelect __instance)
|
||||
{
|
||||
Traverse.Create(__instance).Method("OnClickPlay").GetValue();
|
||||
}
|
||||
}
|
||||
|
||||
// all of the patches below are purely to avoid errors related to not having a HMD or controllers/trackers
|
||||
[HarmonyPatch(typeof(PanelSelect), "OnClickPlay")]
|
||||
class PanelSelect_OnClickPlay_Patch
|
||||
{
|
||||
private static ManualLogSource logger = BepInEx.Logging.Logger.CreateLogSource("PanelSelect_OnClickPlay_Patch");
|
||||
|
||||
static bool Prefix(PanelSelect __instance)
|
||||
{
|
||||
logger.LogInfo($"Inside prefix of PanelSelect.OnClickPlay");
|
||||
|
||||
// this is copied from the source, with player controller related things excluded (comments)
|
||||
PlayerDataManager.Instance.rtr(true);
|
||||
if (PlayerDataManager.Instance.IsDRSMode)
|
||||
EventHandlerProxy<EvtCenter, dep>.sck(dep.DRSTriggerChange);
|
||||
// PlayerController.instance.AllowLaserSwich = false;
|
||||
// PlayerController.instance.interactiveControlManagement.rsn();
|
||||
SfxManager.tba();
|
||||
dhd.tab();
|
||||
Traverse.Create(__instance).Method("AddStatByMode").GetValue();
|
||||
dhj.tcf("Play");
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(PlayerDataManager), "rtr")]
|
||||
class PlayerDataManager_rtr_Patch
|
||||
{
|
||||
static bool Prefix(PanelSelect __instance, bool a)
|
||||
{
|
||||
Traverse.Create(__instance).Field("bvxp").SetValue(a);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(PlayerController), "sze")]
|
||||
class PlayerController_sze_Patch
|
||||
{
|
||||
static bool Prefix(PanelSelect __instance)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(PlayerController), "syy")]
|
||||
class PlayerController_syy_Patch
|
||||
{
|
||||
static bool Prefix(PanelSelect __instance, deo a)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
[HarmonyPatch(typeof(dhj), "tcc")]
|
||||
public static class DhjTccPatch
|
||||
{
|
||||
public static bool Prefix(string a, dhj.dhi[] analyticsParams)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@@ -1,2 +1,36 @@
|
||||
# drs2dd
|
||||
dance rush stardom chart -> dance dash beat map
|
||||
# DANCERUSH STARDOM Track Annotator
|
||||
|
||||
Convert videos of DANCERUSH STARDOM tracks into a defined data schema.
|
||||
|
||||
## Stuff
|
||||
|
||||
### Completed:
|
||||
|
||||
- [x] Detect right & left notes, including position & timing
|
||||
- [x] Define models based on dd & drs schemas
|
||||
- [x] Fetch metadata from data store (https://arcade-songs.zetaraku.dev/drs/)
|
||||
- [x] Simple BepinEx plugin to launch Dance Dash w/o VR
|
||||
|
||||
### To-Do:
|
||||
|
||||
- [ ] Detect down and jump notes (?)
|
||||
- [ ] Determine final data schema
|
||||
- [ ] Write tests based on total note count (https://remywiki.com/AC_DRS)
|
||||
|
||||
---
|
||||
|
||||
Setup:
|
||||
|
||||
- Install Python 3.10 (pyenv recommended)
|
||||
|
||||
- Install the required Python packages
|
||||
```bash
|
||||
python -m venv .venv
|
||||
. .venv/bin/activate
|
||||
python -m pip install -r requirements.txt
|
||||
```
|
||||
|
||||
- Install pre-commit hooks
|
||||
```bash
|
||||
pre-commit install
|
||||
```
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import field
|
||||
|
||||
LEFT_NOTE = 8
|
||||
RIGHT_NOTE = 9
|
||||
|
||||
|
||||
@dataclass
|
||||
class X_Y:
|
||||
x: float | int
|
||||
y: float | int
|
||||
|
||||
|
||||
@dataclass
|
||||
class X_Y_Z(X_Y):
|
||||
z: float | int
|
||||
|
||||
|
||||
@dataclass
|
||||
class DDLineNode:
|
||||
lineGroupId: int
|
||||
indexInLine: int
|
||||
isSliding: bool
|
||||
noteOrder: int
|
||||
time: float
|
||||
position: X_Y
|
||||
position2D: X_Y
|
||||
size: X_Y_Z
|
||||
noteType: int
|
||||
postionOffset: X_Y_Z | None # Yes, postionOffset - not positionOffset.
|
||||
isPlayAudio: bool
|
||||
Ticks: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class DDLSphereNode:
|
||||
noteOrder: int
|
||||
time: float
|
||||
position: X_Y
|
||||
position2D: X_Y
|
||||
size: X_Y_Z
|
||||
noteType: int
|
||||
postionOffset: dict | None
|
||||
isPlayAudio: bool
|
||||
|
||||
|
||||
def create_note_sphere(
|
||||
time: float,
|
||||
position: 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9,
|
||||
note_order: int,
|
||||
note_type: LEFT_NOTE | RIGHT_NOTE,
|
||||
):
|
||||
return DDLSphereNode(
|
||||
noteOrder=note_order,
|
||||
time=time,
|
||||
position=X_Y(x=position, y=0),
|
||||
position2D=X_Y(x=0, y=0),
|
||||
size=X_Y_Z(x=1, y=1, z=1),
|
||||
noteType=note_type,
|
||||
postionOffset=None,
|
||||
isPlayAudio=False,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DDBeatMapData:
|
||||
name: str
|
||||
intervalPerSecond: float
|
||||
gridSize: X_Y
|
||||
planeSize: X_Y
|
||||
orderCountPerBeat: int
|
||||
sphereNodes: list[DDLSphereNode] = field(default_factory=list)
|
||||
lineNodes: list[DDLineNode] = field(default_factory=list)
|
||||
effectNodes: list = field(default_factory=list)
|
||||
roadBlockNodes: list = field(default_factory=list)
|
||||
trapNodes: list = field(default_factory=list)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DDBeatMap:
|
||||
data: DDBeatMapData
|
||||
beatSubs: int
|
||||
BPM: int
|
||||
songStartOffset: float
|
||||
NPS: str
|
||||
developerMode: bool
|
||||
noteSpeed: float
|
||||
noteJumpOffset: float
|
||||
interval: float
|
||||
info: str
|
||||
|
||||
@classmethod
|
||||
def create(
|
||||
cls,
|
||||
name: str,
|
||||
interval_per_second: float,
|
||||
order_count_per_beat: int,
|
||||
sphere_nodes: list[DDLSphereNode],
|
||||
beat_subs: int,
|
||||
bpm: int,
|
||||
info: str,
|
||||
):
|
||||
return cls(
|
||||
data=DDBeatMapData(
|
||||
name=name,
|
||||
intervalPerSecond=interval_per_second,
|
||||
gridSize=X_Y(x=0, y=0),
|
||||
planeSize=X_Y(x=0, y=0),
|
||||
orderCountPerBeat=order_count_per_beat,
|
||||
sphereNodes=sphere_nodes,
|
||||
lineNodes=[],
|
||||
effectNodes=[],
|
||||
roadBlockNodes=[],
|
||||
trapNodes=[],
|
||||
),
|
||||
beatSubs=beat_subs,
|
||||
BPM=bpm,
|
||||
songStartOffset=-0.4, # -0.4 is arbitrary, figure out a better value. (because we detect notes early)
|
||||
NPS='0.0',
|
||||
developerMode=False,
|
||||
noteSpeed=1.0,
|
||||
noteJumpOffset=0.0,
|
||||
interval=1.0,
|
||||
info=info,
|
||||
)
|
||||
@@ -0,0 +1,33 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import field
|
||||
|
||||
|
||||
@dataclass
|
||||
class DRSSheetData:
|
||||
type: str
|
||||
difficulty: str
|
||||
level: str
|
||||
levelValue: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class DRSSongData:
|
||||
songId: str
|
||||
category: str
|
||||
title: str
|
||||
artist: str
|
||||
bpm: int
|
||||
imageName: str
|
||||
version: str | None = None
|
||||
releaseDate: str | None = None
|
||||
isNew: bool | None = None
|
||||
isLocked: bool = False
|
||||
sheets: list[DRSSheetData] = field(default_factory=list)
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, data_dict):
|
||||
sheets_data = data_dict.get('sheets') or []
|
||||
sheets = [DRSSheetData(**sheet) for sheet in sheets_data]
|
||||
return cls(**{**data_dict, 'sheets': sheets})
|
||||
@@ -0,0 +1,156 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import os
|
||||
from dataclasses import asdict
|
||||
|
||||
import cv2
|
||||
from loguru import logger
|
||||
from tqdm import tqdm
|
||||
|
||||
from model.dancedash import create_note_sphere
|
||||
from model.dancedash import DDBeatMap
|
||||
from model.dancedash import LEFT_NOTE
|
||||
from model.dancedash import RIGHT_NOTE
|
||||
from util import ORDER_COUNT_PER_BEAT
|
||||
from util import crop_frame
|
||||
from util import find_l
|
||||
from util import find_r
|
||||
from util import find_stage
|
||||
from util import get_song_metadata_remote
|
||||
from util import map_position_to_dd_x
|
||||
from util import NOTE_SEARCH_AREA
|
||||
from util import SIGN_SEARCH_AREA
|
||||
from util import WORKING_RESOLUTION
|
||||
|
||||
|
||||
@logger.catch
|
||||
def extract_spheres_from_drs_video(video_path: str, bpm: int): # noqa
|
||||
cap = cv2.VideoCapture(video_path)
|
||||
if not cap.isOpened():
|
||||
raise ValueError(f'Unable to open video file: {video_path}')
|
||||
|
||||
total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
||||
video_fps = int(cap.get(cv2.CAP_PROP_FPS))
|
||||
duration_seconds = total_frames / video_fps
|
||||
bps = bpm / 60
|
||||
|
||||
pbar = tqdm(total=total_frames, desc='Finding stage')
|
||||
logger.info(f'Starting to process video: {video_path}')
|
||||
logger.info(f'Total frames: {total_frames}')
|
||||
|
||||
stage_found = False
|
||||
while not stage_found:
|
||||
pbar.update(1)
|
||||
ret, frame = cap.read()
|
||||
if not ret:
|
||||
raise ValueError('Unable to find stage.')
|
||||
|
||||
frame_number = cap.get(cv2.CAP_PROP_POS_FRAMES)
|
||||
if int(frame_number % 5) != 0:
|
||||
continue
|
||||
|
||||
frame = cv2.resize(frame, WORKING_RESOLUTION)
|
||||
search_area = crop_frame(frame, *SIGN_SEARCH_AREA)
|
||||
stage_found = find_stage(search_area)
|
||||
|
||||
pbar.set_description('Finding notes')
|
||||
|
||||
frames_since_l = 0
|
||||
frames_since_r = 0
|
||||
|
||||
spheres = []
|
||||
while True:
|
||||
pbar.update(1)
|
||||
|
||||
ret, frame = cap.read()
|
||||
if not ret:
|
||||
break
|
||||
|
||||
frame = cv2.resize(frame, WORKING_RESOLUTION)
|
||||
# frame_number = cap.get(cv2.CAP_PROP_POS_FRAMES)
|
||||
current_position_seconds = cap.get(cv2.CAP_PROP_POS_MSEC) / 1000.0
|
||||
|
||||
# float between 0 and 1 representing the time in the song
|
||||
progress_percentage = current_position_seconds / duration_seconds
|
||||
|
||||
frames_since_l -= 1
|
||||
frames_since_r -= 1
|
||||
|
||||
note_order = int(bps * current_position_seconds * ORDER_COUNT_PER_BEAT)
|
||||
search_area = crop_frame(frame, *NOTE_SEARCH_AREA)
|
||||
if frames_since_l < 1:
|
||||
if left := find_l(search_area):
|
||||
frames_since_l = 5
|
||||
note = create_note_sphere( # noqa
|
||||
progress_percentage,
|
||||
map_position_to_dd_x(left[0]),
|
||||
note_order,
|
||||
LEFT_NOTE,
|
||||
)
|
||||
spheres.append(note)
|
||||
|
||||
if frames_since_r < 1:
|
||||
if right := find_r(search_area):
|
||||
frames_since_r = 5
|
||||
note = create_note_sphere( # noqa
|
||||
progress_percentage,
|
||||
map_position_to_dd_x(right[0]),
|
||||
note_order,
|
||||
RIGHT_NOTE,
|
||||
)
|
||||
spheres.append(note)
|
||||
|
||||
pbar.close()
|
||||
cap.release()
|
||||
|
||||
return spheres
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
parser = argparse.ArgumentParser(
|
||||
description='Image and video processing tools',
|
||||
)
|
||||
parser.add_argument('video_path', type=str, help='DRS Video path')
|
||||
parser.add_argument('--song-id', type=str, help='BeatMap ID')
|
||||
parser.add_argument('--bpm', type=int, help='BPM of the song')
|
||||
args = parser.parse_args()
|
||||
|
||||
if not args.song_id and not args.bpm:
|
||||
raise ValueError('Must provide either song_id or bpm')
|
||||
|
||||
raw_video_name = os.path.basename(args.video_path)
|
||||
logger.remove() # remove default logger (stdout)
|
||||
logger.add(
|
||||
f'{raw_video_name}.log',
|
||||
backtrace=True,
|
||||
diagnose=True,
|
||||
level='DEBUG' if os.getenv('DEBUG') else 'INFO',
|
||||
)
|
||||
|
||||
if song_metadata := get_song_metadata_remote(args.song_id):
|
||||
name = song_metadata.title
|
||||
bpm = song_metadata.bpm
|
||||
elif args.bpm:
|
||||
name = f'Unknown Song - {raw_video_name}'
|
||||
bpm = args.bpm
|
||||
else:
|
||||
raise ValueError('Unable to find song metadata')
|
||||
|
||||
spheres = extract_spheres_from_drs_video(args.video_path, bpm)
|
||||
beatmap = DDBeatMap.create(
|
||||
name=name,
|
||||
interval_per_second=0.0, # todo: what is this.
|
||||
order_count_per_beat=ORDER_COUNT_PER_BEAT,
|
||||
sphere_nodes=spheres,
|
||||
beat_subs=1, # todo: what is this.
|
||||
bpm=bpm,
|
||||
info='',
|
||||
)
|
||||
|
||||
beatmap_dict = asdict(beatmap)
|
||||
with open(f'{raw_video_name}.json', 'w') as f:
|
||||
json.dump(beatmap_dict, f, indent=4)
|
||||
|
||||
logger.info(f'Finished processing video: {args.video_path}')
|
||||
@@ -0,0 +1,10 @@
|
||||
pre-commit
|
||||
pytest~=7.4.0
|
||||
pytest-mock
|
||||
python-dotenv
|
||||
opencv-python~=4.8.0.74
|
||||
numpy~=1.25.2
|
||||
Pillow~=10.0.0
|
||||
loguru
|
||||
tqdm
|
||||
requests
|
||||
+11094
File diff suppressed because it is too large
Load Diff
Binary file not shown.
|
After Width: | Height: | Size: 24 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 3.6 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 4.1 KiB |
@@ -0,0 +1,5 @@
|
||||
from __future__ import annotations
|
||||
|
||||
|
||||
def test_extract_lines_from_drs_video():
|
||||
assert True # todo: implement your test here
|
||||
@@ -0,0 +1,171 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from unittest.mock import Mock
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from util import crop_frame
|
||||
from util import get_song_metadata_remote
|
||||
from util import L_TEMPLATE
|
||||
from util import map_position_to_dd_x
|
||||
from util import match_template
|
||||
from util import R_TEMPLATE
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'image, template, threshold, expected_result',
|
||||
[
|
||||
(
|
||||
np.array(L_TEMPLATE),
|
||||
np.array(L_TEMPLATE),
|
||||
0.8,
|
||||
(0, 0, 60, 30),
|
||||
),
|
||||
(
|
||||
np.array(L_TEMPLATE),
|
||||
np.array(R_TEMPLATE),
|
||||
0.8,
|
||||
None,
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_match_template(image, template, threshold, expected_result):
|
||||
result = match_template(image, template, threshold)
|
||||
assert result == expected_result
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'input_number, expected_output', [
|
||||
(30, 1), # Below minimum
|
||||
(31, 1), # Minimum value
|
||||
(100, 2), # Somewhere in between
|
||||
(155, 3), # Somewhere in between
|
||||
(522, 9), # Maximum value
|
||||
(523, 9), # Above maximum
|
||||
],
|
||||
)
|
||||
def test_map_position_to_dd_x(input_number, expected_output):
|
||||
result = map_position_to_dd_x(input_number)
|
||||
assert result == expected_output
|
||||
|
||||
|
||||
def test_get_song_metadata_remote_remote_data(mocker):
|
||||
mocker.patch.dict(os.environ, {'REMOTE_DATA': 'True'})
|
||||
mock_requests_get = mocker.patch('requests.get')
|
||||
|
||||
response_mock = Mock()
|
||||
response_mock.json.return_value = _SAMPLE_DRS_JSON_DATA
|
||||
mock_requests_get.return_value = response_mock
|
||||
|
||||
result = get_song_metadata_remote('1')
|
||||
|
||||
assert result is not None
|
||||
assert result.songId == '1'
|
||||
assert result.title == 'Song 1'
|
||||
|
||||
|
||||
_SAMPLE_DRS_JSON_DATA = {
|
||||
'songs': [
|
||||
{
|
||||
'songId': '1',
|
||||
'title': 'Song 1',
|
||||
'category': 'Category 1',
|
||||
'artist': 'Artist 1',
|
||||
'bpm': 120,
|
||||
'imageName': 'song1.jpg',
|
||||
},
|
||||
{
|
||||
'songId': '2',
|
||||
'title': 'Song 2',
|
||||
'category': 'Category 2',
|
||||
'artist': 'Artist 2',
|
||||
'bpm': 140,
|
||||
'imageName': 'song2.jpg',
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def test_get_song_metadata_remote_local_data(mocker):
|
||||
mocker.patch.dict(os.environ, {'REMOTE_DATA': 'False'})
|
||||
|
||||
result = get_song_metadata_remote('50th Memorial Songs -Beginning Story-')
|
||||
|
||||
assert result is not None
|
||||
assert result.songId == '50th Memorial Songs -Beginning Story-'
|
||||
assert result.title == '50th Memorial Songs -Beginning Story-'
|
||||
|
||||
|
||||
def test_get_song_metadata_remote_song_id_none(mocker):
|
||||
mocker.patch.dict(os.environ, {'REMOTE_DATA': 'False'})
|
||||
|
||||
result = get_song_metadata_remote('does not exist')
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
_TEST_FRAME = np.array(
|
||||
[
|
||||
[1, 2, 3, 4, 5],
|
||||
[6, 7, 8, 9, 10],
|
||||
[11, 12, 13, 14, 15],
|
||||
[16, 17, 18, 19, 20],
|
||||
[21, 22, 23, 24, 25],
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
'x, y, width, height, expected_result', [
|
||||
(
|
||||
1, 1, 3, 3, np.array(
|
||||
[
|
||||
[7, 8, 9],
|
||||
[12, 13, 14],
|
||||
[17, 18, 19],
|
||||
],
|
||||
),
|
||||
),
|
||||
(
|
||||
0, 0, 2, 2, np.array(
|
||||
[
|
||||
[1, 2],
|
||||
[6, 7],
|
||||
],
|
||||
),
|
||||
),
|
||||
(
|
||||
2, 2, 2, 2, np.array(
|
||||
[
|
||||
[13, 14],
|
||||
[18, 19],
|
||||
],
|
||||
),
|
||||
),
|
||||
(
|
||||
3, 3, 3, 3, np.array(
|
||||
[
|
||||
[19, 20],
|
||||
[24, 25],
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
)
|
||||
def test_crop_frame(x, y, width, height, expected_result):
|
||||
result = crop_frame(_TEST_FRAME, x, y, width, height)
|
||||
np.testing.assert_array_equal(result, expected_result)
|
||||
|
||||
|
||||
def test_find_l():
|
||||
assert True # todo
|
||||
|
||||
|
||||
def test_find_r():
|
||||
assert True # todo
|
||||
|
||||
|
||||
def test_find_stage():
|
||||
assert True # todo
|
||||
@@ -0,0 +1,80 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from functools import partial
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import requests
|
||||
from PIL import Image
|
||||
|
||||
from model.dancerush import DRSSongData
|
||||
|
||||
ORDER_COUNT_PER_BEAT = 24
|
||||
DEFAULT_THRESHOLD = 0.8
|
||||
|
||||
|
||||
def match_template(image, template, threshold=DEFAULT_THRESHOLD) -> tuple[int, int, int, int]:
|
||||
gray_image = cv2.cvtColor(np.array(image), cv2.COLOR_RGB2GRAY)
|
||||
gray_template = cv2.cvtColor(np.array(template), cv2.COLOR_RGB2GRAY)
|
||||
result = cv2.matchTemplate(gray_image, gray_template, cv2.TM_CCOEFF_NORMED)
|
||||
loc = np.where(result >= threshold)
|
||||
if loc[0].size:
|
||||
x, y = loc[1][0], loc[0][0]
|
||||
width, height = gray_template.shape[::-1]
|
||||
return int(x), int(y), int(width), int(height)
|
||||
|
||||
|
||||
def map_position_to_dd_x(number):
|
||||
minimum, maximum = 31, 522 # 31 is the leftmost, 522 is the rightmost
|
||||
if number < minimum:
|
||||
return 1
|
||||
elif number > maximum:
|
||||
return 9
|
||||
percentage = (number - minimum) / (maximum - minimum)
|
||||
return int(percentage * 8) + 1
|
||||
|
||||
|
||||
def _get_song_metadata_json():
|
||||
if os.getenv('REMOTE_DATA'):
|
||||
return requests.get(DRS_DATA_LINK).json()
|
||||
return json.load(open('resources/data.json', encoding='utf-8'))
|
||||
|
||||
|
||||
def get_song_metadata_remote(song_id: str) -> DRSSongData | None:
|
||||
if not song_id:
|
||||
return None
|
||||
drs_data = _get_song_metadata_json()
|
||||
song_id_to_song = {s['songId']: s for s in drs_data['songs']}
|
||||
if metadata := song_id_to_song.get(song_id):
|
||||
return DRSSongData.from_dict(metadata)
|
||||
|
||||
|
||||
def crop_frame(frame, x, y, width, height):
|
||||
cropped_frame = frame[y:y + height, x:x + width]
|
||||
return cropped_frame
|
||||
|
||||
|
||||
DRS_DATA_LINK = 'https://dp4p6x0xfi5o9.cloudfront.net/drs/data.json'
|
||||
WORKING_RESOLUTION = 1920, 1080
|
||||
|
||||
L_TEMPLATE = Image.open(
|
||||
'resources/l_template.png',
|
||||
)
|
||||
R_TEMPLATE = Image.open(
|
||||
'resources/r_template.png',
|
||||
)
|
||||
|
||||
NOTE_SEARCH_AREA = 650, 475, 618, 105
|
||||
|
||||
find_l = partial(match_template, template=L_TEMPLATE)
|
||||
find_r = partial(match_template, template=R_TEMPLATE)
|
||||
|
||||
SIGN_TEMPLATE = Image.open(
|
||||
'resources/drs_sign_template.png',
|
||||
)
|
||||
|
||||
SIGN_SEARCH_AREA = 850, 143, 223, 49
|
||||
|
||||
find_stage = partial(match_template, template=SIGN_TEMPLATE)
|
||||
Reference in New Issue
Block a user