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