1# Copyright 2015 The Chromium Authors. All rights reserved. 2# Use of this source code is governed by a BSD-style license that can be 3# found in the LICENSE file. 4 5import logging 6 7from telemetry.util import statistics 8 9DISPLAY_HERTZ = 60.0 10VSYNC_DURATION = 1e6 / DISPLAY_HERTZ 11# When to consider a frame frozen (in VSYNC units): meaning 1 initial 12# frame + 5 repeats of that frame. 13FROZEN_THRESHOLD = 6 14# Severity factor. 15SEVERITY = 3 16 17IDEAL_RENDER_INSTANT = 'Ideal Render Instant' 18ACTUAL_RENDER_BEGIN = 'Actual Render Begin' 19ACTUAL_RENDER_END = 'Actual Render End' 20SERIAL = 'Serial' 21 22 23class TimeStats(object): 24 """Stats container for webrtc rendering metrics.""" 25 26 def __init__(self, drift_time=None, mean_drift_time=None, 27 std_dev_drift_time=None, percent_badly_out_of_sync=None, 28 percent_out_of_sync=None, smoothness_score=None, freezing_score=None, 29 rendering_length_error=None, fps=None, frame_distribution=None): 30 self.drift_time = drift_time 31 self.mean_drift_time = mean_drift_time 32 self.std_dev_drift_time = std_dev_drift_time 33 self.percent_badly_out_of_sync = percent_badly_out_of_sync 34 self.percent_out_of_sync = percent_out_of_sync 35 self.smoothness_score = smoothness_score 36 self.freezing_score = freezing_score 37 self.rendering_length_error = rendering_length_error 38 self.fps = fps 39 self.frame_distribution = frame_distribution 40 self.invalid_data = False 41 42 43 44class WebMediaPlayerMsRenderingStats(object): 45 """Analyzes events of WebMediaPlayerMs type.""" 46 47 def __init__(self, events): 48 """Save relevant events according to their stream.""" 49 self.stream_to_events = self._MapEventsToStream(events) 50 51 def _IsEventValid(self, event): 52 """Check that the needed arguments are present in event. 53 54 Args: 55 event: event to check. 56 57 Returns: 58 True is event is valid, false otherwise.""" 59 if not event.args: 60 return False 61 mandatory = [ACTUAL_RENDER_BEGIN, ACTUAL_RENDER_END, 62 IDEAL_RENDER_INSTANT, SERIAL] 63 for parameter in mandatory: 64 if not parameter in event.args: 65 return False 66 return True 67 68 def _MapEventsToStream(self, events): 69 """Build a dictionary of events indexed by stream. 70 71 The events of interest have a 'Serial' argument which represents the 72 stream ID. The 'Serial' argument identifies the local or remote nature of 73 the stream with a least significant bit of 0 or 1 as well as the hash 74 value of the video track's URL. So stream::=hash(0|1} . The method will 75 then list the events of the same stream in a frame_distribution on stream 76 id. Practically speaking remote streams have an odd stream id and local 77 streams have a even stream id. 78 Args: 79 events: Telemetry WebMediaPlayerMs events. 80 81 Returns: 82 A dict of stream IDs mapped to events on that stream. 83 """ 84 stream_to_events = {} 85 for event in events: 86 if not self._IsEventValid(event): 87 # This is not a render event, skip it. 88 continue 89 stream = event.args[SERIAL] 90 events_for_stream = stream_to_events.setdefault(stream, []) 91 events_for_stream.append(event) 92 93 return stream_to_events 94 95 def _GetCadence(self, relevant_events): 96 """Calculate the apparent cadence of the rendering. 97 98 In this paragraph I will be using regex notation. What is intended by the 99 word cadence is a sort of extended instantaneous 'Cadence' (thus not 100 necessarily periodic). Just as an example, a normal 'Cadence' could be 101 something like [2 3] which means possibly an observed frame persistence 102 progression of [{2 3}+] for an ideal 20FPS video source. So what we are 103 calculating here is the list of frame persistence, kind of a 104 'Proto-Cadence', but cadence is shorter so we abuse the word. 105 106 Args: 107 relevant_events: list of Telemetry events. 108 109 Returns: 110 a list of frame persistence values. 111 """ 112 cadence = [] 113 frame_persistence = 0 114 old_ideal_render = 0 115 for event in relevant_events: 116 if not self._IsEventValid(event): 117 # This event is not a render event so skip it. 118 continue 119 if event.args[IDEAL_RENDER_INSTANT] == old_ideal_render: 120 frame_persistence += 1 121 else: 122 cadence.append(frame_persistence) 123 frame_persistence = 1 124 old_ideal_render = event.args[IDEAL_RENDER_INSTANT] 125 cadence.append(frame_persistence) 126 cadence.pop(0) 127 return cadence 128 129 def _GetSourceToOutputDistribution(self, cadence): 130 """Create distribution for the cadence frame display values. 131 132 If the overall display distribution is A1:A2:..:An, this will tell us how 133 many times a frame stays displayed during Ak*VSYNC_DURATION, also known as 134 'source to output' distribution. Or in other terms: 135 a distribution B::= let C be the cadence, B[k]=p with k in Unique(C) 136 and p=Card(k in C). 137 138 Args: 139 cadence: list of frame persistence values. 140 141 Returns: 142 a dictionary containing the distribution 143 """ 144 frame_distribution = {} 145 for ticks in cadence: 146 ticks_so_far = frame_distribution.setdefault(ticks, 0) 147 frame_distribution[ticks] = ticks_so_far + 1 148 return frame_distribution 149 150 def _GetFpsFromCadence(self, frame_distribution): 151 """Calculate the apparent FPS from frame distribution. 152 153 Knowing the display frequency and the frame distribution, it is possible to 154 calculate the video apparent frame rate as played by WebMediaPlayerMs 155 module. 156 157 Args: 158 frame_distribution: the source to output distribution. 159 160 Returns: 161 the video apparent frame rate. 162 """ 163 number_frames = sum(frame_distribution.values()) 164 number_vsyncs = sum([ticks * frame_distribution[ticks] 165 for ticks in frame_distribution]) 166 mean_ratio = float(number_vsyncs) / number_frames 167 return DISPLAY_HERTZ / mean_ratio 168 169 def _GetFrozenFramesReports(self, frame_distribution): 170 """Find evidence of frozen frames in distribution. 171 172 For simplicity we count as freezing the frames that appear at least five 173 times in a row counted from 'Ideal Render Instant' perspective. So let's 174 say for 1 source frame, we rendered 6 frames, then we consider 5 of these 175 rendered frames as frozen. But we mitigate this by saying anything under 176 5 frozen frames will not be counted as frozen. 177 178 Args: 179 frame_distribution: the source to output distribution. 180 181 Returns: 182 a list of dicts whose keys are ('frozen_frames', 'occurrences'). 183 """ 184 frozen_frames = [] 185 frozen_frame_vsyncs = [ticks for ticks in frame_distribution if ticks >= 186 FROZEN_THRESHOLD] 187 for frozen_frames_vsync in frozen_frame_vsyncs: 188 logging.debug('%s frames not updated after %s vsyncs', 189 frame_distribution[frozen_frames_vsync], frozen_frames_vsync) 190 frozen_frames.append( 191 {'frozen_frames': frozen_frames_vsync - 1, 192 'occurrences': frame_distribution[frozen_frames_vsync]}) 193 return frozen_frames 194 195 def _FrozenPenaltyWeight(self, number_frozen_frames): 196 """Returns the weighted penalty for a number of frozen frames. 197 198 As mentioned earlier, we count for frozen anything above 6 vsync display 199 duration for the same 'Initial Render Instant', which is five frozen 200 frames. 201 202 Args: 203 number_frozen_frames: number of frozen frames. 204 205 Returns: 206 the penalty weight (int) for that number of frozen frames. 207 """ 208 209 penalty = { 210 0: 0, 211 1: 0, 212 2: 0, 213 3: 0, 214 4: 0, 215 5: 1, 216 6: 5, 217 7: 15, 218 8: 25 219 } 220 weight = penalty.get(number_frozen_frames, 8 * (number_frozen_frames - 4)) 221 return weight 222 223 def _IsRemoteStream(self, stream): 224 """Check if stream is remote.""" 225 return stream % 2 226 227 def _GetDrifTimeStats(self, relevant_events, cadence): 228 """Get the drift time statistics. 229 230 This method will calculate drift_time stats, that is to say : 231 drift_time::= list(actual render begin - ideal render). 232 rendering_length error::= the rendering length error. 233 234 Args: 235 relevant_events: events to get drift times stats from. 236 cadence: list of frame persistence values. 237 238 Returns: 239 a tuple of (drift_time, rendering_length_error). 240 """ 241 drift_time = [] 242 old_ideal_render = 0 243 discrepancy = [] 244 index = 0 245 for event in relevant_events: 246 current_ideal_render = event.args[IDEAL_RENDER_INSTANT] 247 if current_ideal_render == old_ideal_render: 248 # Skip to next event because we're looking for a source frame. 249 continue 250 actual_render_begin = event.args[ACTUAL_RENDER_BEGIN] 251 drift_time.append(actual_render_begin - current_ideal_render) 252 discrepancy.append(abs(current_ideal_render - old_ideal_render 253 - VSYNC_DURATION * cadence[index])) 254 old_ideal_render = current_ideal_render 255 index += 1 256 discrepancy.pop(0) 257 last_ideal_render = relevant_events[-1].args[IDEAL_RENDER_INSTANT] 258 first_ideal_render = relevant_events[0].args[IDEAL_RENDER_INSTANT] 259 rendering_length_error = 100.0 * (sum([x for x in discrepancy]) / 260 (last_ideal_render - first_ideal_render)) 261 262 return drift_time, rendering_length_error 263 264 def _GetSmoothnessStats(self, norm_drift_time): 265 """Get the smoothness stats from the normalized drift time. 266 267 This method will calculate the smoothness score, along with the percentage 268 of frames badly out of sync and the percentage of frames out of sync. To be 269 considered badly out of sync, a frame has to have missed rendering by at 270 least 2*VSYNC_DURATION. To be considered out of sync, a frame has to have 271 missed rendering by at least one VSYNC_DURATION. 272 The smoothness score is a measure of how out of sync the frames are. 273 274 Args: 275 norm_drift_time: normalized drift time. 276 277 Returns: 278 a tuple of (percent_badly_oos, percent_out_of_sync, smoothness_score) 279 """ 280 # How many times is a frame later/earlier than T=2*VSYNC_DURATION. Time is 281 # in microseconds. 282 frames_severely_out_of_sync = len( 283 [x for x in norm_drift_time if abs(x) > 2 * VSYNC_DURATION]) 284 percent_badly_oos = ( 285 100.0 * frames_severely_out_of_sync / len(norm_drift_time)) 286 287 # How many times is a frame later/earlier than VSYNC_DURATION. 288 frames_out_of_sync = len( 289 [x for x in norm_drift_time if abs(x) > VSYNC_DURATION]) 290 percent_out_of_sync = ( 291 100.0 * frames_out_of_sync / len(norm_drift_time)) 292 293 frames_oos_only_once = frames_out_of_sync - frames_severely_out_of_sync 294 295 # Calculate smoothness metric. From the formula, we can see that smoothness 296 # score can be negative. 297 smoothness_score = 100.0 - 100.0 * (frames_oos_only_once + 298 SEVERITY * frames_severely_out_of_sync) / len(norm_drift_time) 299 300 # Minimum smoothness_score value allowed is zero. 301 if smoothness_score < 0: 302 smoothness_score = 0 303 304 return (percent_badly_oos, percent_out_of_sync, smoothness_score) 305 306 def _GetFreezingScore(self, frame_distribution): 307 """Get the freezing score.""" 308 309 # The freezing score is based on the source to output distribution. 310 number_vsyncs = sum([n * frame_distribution[n] 311 for n in frame_distribution]) 312 frozen_frames = self._GetFrozenFramesReports(frame_distribution) 313 314 # Calculate freezing metric. 315 # Freezing metric can be negative if things are really bad. In that case we 316 # change it to zero as minimum valud. 317 freezing_score = 100.0 318 for frozen_report in frozen_frames: 319 weight = self._FrozenPenaltyWeight(frozen_report['frozen_frames']) 320 freezing_score -= ( 321 100.0 * frozen_report['occurrences'] / number_vsyncs * weight) 322 if freezing_score < 0: 323 freezing_score = 0 324 325 return freezing_score 326 327 def GetTimeStats(self): 328 """Calculate time stamp stats for all remote stream events.""" 329 stats = {} 330 for stream, relevant_events in self.stream_to_events.iteritems(): 331 if len(relevant_events) == 1: 332 logging.debug('Found a stream=%s with just one event', stream) 333 continue 334 if not self._IsRemoteStream(stream): 335 logging.info('Skipping processing of local stream: %s', stream) 336 continue 337 338 cadence = self._GetCadence(relevant_events) 339 if not cadence: 340 stats = TimeStats() 341 stats.invalid_data = True 342 return stats 343 344 frame_distribution = self._GetSourceToOutputDistribution(cadence) 345 fps = self._GetFpsFromCadence(frame_distribution) 346 347 drift_time_stats = self._GetDrifTimeStats(relevant_events, cadence) 348 (drift_time, rendering_length_error) = drift_time_stats 349 350 # Drift time normalization. 351 mean_drift_time = statistics.ArithmeticMean(drift_time) 352 norm_drift_time = [abs(x - mean_drift_time) for x in drift_time] 353 354 smoothness_stats = self._GetSmoothnessStats(norm_drift_time) 355 (percent_badly_oos, percent_out_of_sync, 356 smoothness_score) = smoothness_stats 357 358 freezing_score = self._GetFreezingScore(frame_distribution) 359 360 stats = TimeStats(drift_time=drift_time, 361 percent_badly_out_of_sync=percent_badly_oos, 362 percent_out_of_sync=percent_out_of_sync, 363 smoothness_score=smoothness_score, freezing_score=freezing_score, 364 rendering_length_error=rendering_length_error, fps=fps, 365 frame_distribution=frame_distribution) 366 return stats 367