1
0
mirror of https://github.com/ppy/osu.git synced 2026-05-23 11:40:28 +08:00

Merge pull request #32601 from sineplusx/median-offset

Use median instead of mean for average hit error calculations
This commit is contained in:
Dean Herbert
2025-04-03 18:56:04 +09:00
committed by GitHub
Unverified
5 changed files with 28 additions and 29 deletions
@@ -50,21 +50,17 @@ namespace osu.Game.Tests.Visual.Gameplay
{
AddStep("Set short reference score", () =>
{
// 50 events total. one of them (head circle) being timed / having hitwindows, rest having no hitwindows
List<HitEvent> hitEvents =
[
// 10 events total. one of them (head circle) being timed / having hitwindows, rest having no hitwindows
new HitEvent(30, 1, HitResult.LargeTickHit, new SliderHeadCircle { ClassicSliderBehaviour = true }, null, null),
new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null),
new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null),
new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null),
new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null),
new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null),
new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null),
new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null),
new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null),
new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null),
];
for (int i = 0; i < 49; i++)
{
hitEvents.Add(new HitEvent(0, 1, HitResult.LargeTickHit, new SliderTick(), null, null));
}
foreach (var ev in hitEvents)
ev.HitObject.ApplyDefaults(new ControlPointInfo(), new BeatmapDifficulty());
@@ -40,10 +40,10 @@ namespace osu.Game.Configuration
if (newScore.Mods.Any(m => !m.UserPlayable || m is IHasNoTimedInputs))
return;
if (newScore.HitEvents.Count < 10)
if (newScore.HitEvents.Count < 50)
return;
if (newScore.HitEvents.CalculateAverageHitError() is not double averageError)
if (newScore.HitEvents.CalculateMedianHitError() is not double medianError)
return;
// keep a sane maximum number of entries.
@@ -51,7 +51,7 @@ namespace osu.Game.Configuration
averageHitErrorHistory.RemoveAt(0);
double globalOffset = configManager.Get<double>(OsuSetting.AudioOffset);
averageHitErrorHistory.Add(new DataPoint(averageError, globalOffset));
averageHitErrorHistory.Add(new DataPoint(medianError, globalOffset));
}
public void ClearHistory() => averageHitErrorHistory.Clear();
@@ -55,20 +55,23 @@ namespace osu.Game.Rulesets.Scoring
}
/// <summary>
/// Calculates the average hit offset/error for a sequence of <see cref="HitEvent"/>s, where negative numbers mean the user hit too early on average.
/// Calculates the median hit offset/error for a sequence of <see cref="HitEvent"/>s, where negative numbers mean the user hit too early on average.
/// </summary>
/// <returns>
/// A non-null <see langword="double"/> value if unstable rate could be calculated,
/// and <see langword="null"/> if unstable rate cannot be calculated due to <paramref name="hitEvents"/> being empty.
/// </returns>
public static double? CalculateAverageHitError(this IEnumerable<HitEvent> hitEvents)
public static double? CalculateMedianHitError(this IEnumerable<HitEvent> hitEvents)
{
double[] timeOffsets = hitEvents.Where(AffectsUnstableRate).Select(ev => ev.TimeOffset).ToArray();
double[] timeOffsets = hitEvents.Where(AffectsUnstableRate).Select(ev => ev.TimeOffset).OrderBy(x => x).ToArray();
if (timeOffsets.Length == 0)
return null;
return timeOffsets.Average();
int center = timeOffsets.Length / 2;
// Use average of the 2 central values if length is even
return timeOffsets.Length % 2 == 0 ? (timeOffsets[center - 1] + timeOffsets[center]) / 2 : timeOffsets[center];
}
public static bool AffectsUnstableRate(HitEvent e) => AffectsUnstableRate(e.HitObject, e.Result);
@@ -63,11 +63,11 @@ namespace osu.Game.Screens.Play.PlayerSettings
[Resolved]
private IGameplayClock? gameplayClock { get; set; }
private double lastPlayAverage;
private double lastPlayMedian;
private double lastPlayBeatmapOffset;
private HitEventTimingDistributionGraph? lastPlayGraph;
private SettingsButton? useAverageButton;
private SettingsButton? calibrateFromLastPlayButton;
private IDisposable? beatmapOffsetSubscription;
@@ -196,7 +196,7 @@ namespace osu.Game.Screens.Play.PlayerSettings
var hitEvents = score.NewValue.HitEvents;
if (!(hitEvents.CalculateAverageHitError() is double average))
if (!(hitEvents.CalculateMedianHitError() is double median))
return;
referenceScoreContainer.Children = new Drawable[]
@@ -210,7 +210,7 @@ namespace osu.Game.Screens.Play.PlayerSettings
// affecting unstable rate here is used as a substitute of determining if a hit event represents a *timed* hit event,
// i.e. an user input that the user had to *time to the track*,
// i.e. one that it *makes sense to use* when doing anything with timing and offsets.
if (hitEvents.Count(HitEventExtensions.AffectsUnstableRate) < 10)
if (hitEvents.Count(HitEventExtensions.AffectsUnstableRate) < 50)
{
referenceScoreContainer.AddRange(new Drawable[]
{
@@ -226,7 +226,7 @@ namespace osu.Game.Screens.Play.PlayerSettings
return;
}
lastPlayAverage = average;
lastPlayMedian = median;
lastPlayBeatmapOffset = Current.Value;
LinkFlowContainer globalOffsetText;
@@ -239,7 +239,7 @@ namespace osu.Game.Screens.Play.PlayerSettings
Height = 50,
},
new AverageHitError(hitEvents),
useAverageButton = new SettingsButton
calibrateFromLastPlayButton = new SettingsButton
{
Text = BeatmapOffsetControlStrings.CalibrateUsingLastPlay,
Action = () =>
@@ -247,7 +247,7 @@ namespace osu.Game.Screens.Play.PlayerSettings
if (Current.Disabled)
return;
Current.Value = lastPlayBeatmapOffset - lastPlayAverage;
Current.Value = lastPlayBeatmapOffset - lastPlayMedian;
lastAppliedScore.Value = ReferenceScore.Value;
},
},
@@ -281,8 +281,8 @@ namespace osu.Game.Screens.Play.PlayerSettings
bool allow = allowOffsetAdjust;
if (useAverageButton != null)
useAverageButton.Enabled.Value = allow && !Precision.AlmostEquals(lastPlayAverage, adjustmentSinceLastPlay, Current.Precision / 2);
if (calibrateFromLastPlayButton != null)
calibrateFromLastPlayButton.Enabled.Value = allow && !Precision.AlmostEquals(lastPlayMedian, adjustmentSinceLastPlay, Current.Precision / 2);
Current.Disabled = !allow;
}
@@ -8,18 +8,18 @@ using osu.Game.Rulesets.Scoring;
namespace osu.Game.Screens.Ranking.Statistics
{
/// <summary>
/// Displays the unstable rate statistic for a given play.
/// Displays the average hit error statistic for a given play.
/// </summary>
public partial class AverageHitError : SimpleStatisticItem<double?>
{
/// <summary>
/// Creates and computes an <see cref="AverageHitError"/> statistic.
/// </summary>
/// <param name="hitEvents">Sequence of <see cref="HitEvent"/>s to calculate the unstable rate based on.</param>
/// <param name="hitEvents">Sequence of <see cref="HitEvent"/>s to calculate the average hit error based on.</param>
public AverageHitError(IEnumerable<HitEvent> hitEvents)
: base("Average Hit Error")
{
Value = hitEvents.CalculateAverageHitError();
Value = hitEvents.CalculateMedianHitError();
}
protected override string DisplayValue(double? value) => value == null ? "(not available)" : $"{Math.Abs(value.Value):N2} ms {(value.Value < 0 ? "early" : "late")}";