browsertime 19.1.0 → 19.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,1969 +0,0 @@
1
- #!/usr/bin/env python3
2
- """
3
- Copyright (c) 2014, Google Inc.
4
- All rights reserved.
5
-
6
- Redistribution and use in source and binary forms, with or without modification,
7
- are permitted provided that the following conditions are met:
8
-
9
- * Redistributions of source code must retain the above copyright notice,
10
- this list of conditions and the following disclaimer.
11
- * Redistributions in binary form must reproduce the above copyright notice,
12
- this list of conditions and the following disclaimer in the documentation
13
- and/or other materials provided with the distribution.
14
- * Neither the name of the company nor the names of its contributors may be
15
- used to endorse or promote products derived from this software without
16
- specific prior written permission.
17
-
18
- THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
19
- "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
20
- LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
21
- A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT OWNER OR
22
- CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
23
- EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO,
24
- PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR
25
- PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
26
- LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING
27
- NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
28
- SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE."""
29
- #
30
- # The original script from Google was heavily modified for the Browsertime
31
- # project.
32
- #
33
- import gc
34
- import glob
35
- import gzip
36
- import json
37
- import logging
38
- import math
39
- import os
40
- import re
41
- import sys
42
- import shutil
43
- import subprocess
44
- import tempfile
45
-
46
-
47
- GZIP_TEXT = "wt"
48
- GZIP_READ_TEXT = "rt"
49
-
50
- # Globals
51
- options = None
52
- client_viewport = None
53
- frame_cache = {}
54
-
55
-
56
- # #################################################################################################
57
- # Replacement methods for ImageMagick to Python conversion
58
- # #################################################################################################
59
-
60
-
61
- def compare(img1, img2, fuzz=0.10):
62
- """Calculate the Absolute Error count between given images."""
63
- try:
64
- import numpy as np
65
-
66
- img1_data = np.array(img1)
67
- img2_data = np.array(img2)
68
-
69
- inds = np.argwhere(
70
- np.isclose(img1_data[:, :, 0], img2_data[:, :, 0], atol=fuzz * 255)
71
- & np.isclose(img1_data[:, :, 1], img2_data[:, :, 1], atol=fuzz * 255)
72
- & np.isclose(img1_data[:, :, 2], img2_data[:, :, 2], atol=fuzz * 255)
73
- )
74
-
75
- return (img1_data.shape[0] * img1_data.shape[1]) - len(inds)
76
- except BaseException as e:
77
- logging.exception(e)
78
- return None
79
-
80
-
81
- def crop_im(img, crop_x, crop_y, crop_x_offset, crop_y_offset, gravity=None):
82
- """Crop an image.
83
-
84
- If gravity is equal to "center", the crop region will
85
- first be centered before applying the crop.
86
- """
87
- try:
88
- import numpy as np
89
- from PIL import Image
90
-
91
- img = np.array(img)
92
-
93
- base_x = 0
94
- base_y = 0
95
-
96
- height, width, _ = img.shape
97
- if gravity == "center":
98
- base_x = width // 2
99
- base_y = height // 2
100
-
101
- base_x -= crop_x // 2
102
- base_y -= crop_y // 2
103
-
104
- # Handle the boundaries of the crop using max to prevent
105
- # negatives, and min to prevent going over the othersde of
106
- # the image
107
- start_x = min(width - 1, max(base_x + crop_x_offset, 0))
108
- start_y = min(height - 1, max(base_y + crop_y_offset, 0))
109
-
110
- end_x = min(width - 1, max(start_x + crop_x, 0))
111
- end_y = min(height - 1, max(start_y + crop_y, 0))
112
-
113
- if len(img[start_y:end_y, start_x:end_x, :]) == 0:
114
- raise Exception(
115
- f"Cropped image is empty. Image dimensions: {img.shape}, "
116
- f"Crop Region: {crop_x}, {crop_y}, {crop_x_offset}, {crop_y_offset}"
117
- )
118
-
119
- return Image.fromarray(img[start_y:end_y, start_x:end_x, :])
120
- except BaseException as e:
121
- logging.exception(e)
122
- return None
123
-
124
-
125
- def resize(img, width, height):
126
- """Resize an image to the given width, and height."""
127
-
128
- try:
129
- from PIL import Image
130
-
131
- try:
132
- # If it's a numpy array, convert it first
133
- img = Image.fromarray(img)
134
- except:
135
- pass
136
-
137
- return img.resize((width, height), resample=Image.LANCZOS)
138
- except BaseException as e:
139
- logging.exception(e)
140
- return None
141
-
142
-
143
- def scale(img, maxsize):
144
- """Scale an image to the given max size."""
145
- width, height = img.size
146
- ratio = min(float(maxsize) / width, float(maxsize) / height)
147
- return resize(img, int(width * ratio), int(height * ratio))
148
-
149
-
150
- def mask(
151
- img, x_mask, y_mask, x_offset, y_offset, color=(255, 255, 255), insert_img=None
152
- ):
153
- """Mask an image.
154
-
155
- If insert_img is provided, the image given will mask the region
156
- specified. Otherwise, by default, the region specified will be covered
157
- in white - change color to change the mask color.
158
- """
159
- try:
160
- import numpy as np
161
- from PIL import Image
162
-
163
- img_data = np.array(img)
164
- if insert_img is not None:
165
- insert_img_data = np.array(insert_img)
166
- img_data[
167
- y_offset : y_offset + y_mask, x_offset : x_offset + x_mask, :
168
- ] = insert_img
169
- else:
170
- img_data[
171
- y_offset : y_offset + y_mask, x_offset : x_offset + x_mask, :
172
- ] = color
173
-
174
- return Image.fromarray(img_data)
175
- except BaseException as e:
176
- logging.exception(e)
177
- return None
178
-
179
-
180
- def blank_frame(file, color="white"):
181
- """Return a new blank frame that has the same dimensions as file."""
182
- try:
183
- from PIL import Image
184
-
185
- with Image.open(file) as im:
186
- width, height = im.size
187
- return Image.new("RGB", (width, height), color=color)
188
- except BaseException as e:
189
- logging.exception(e)
190
- return None
191
-
192
-
193
- def edges_im(img):
194
- """Find the edges of the given image.
195
-
196
- First, we apply a gaussian filter using a kernal of radius=13,
197
- and sigma=1 to a grayscale version of the image. Then we apply
198
- CED to find the edges.
199
-
200
- We calculate the hysterisis thresholds for the CED using the min and max
201
- vaues of the blurred image. We use 10% as the lower threshold,
202
- and 30% as the upper threshold.
203
- """
204
- try:
205
- import cv2
206
- import numpy as np
207
- from PIL import Image, ImageOps
208
-
209
- gs_img = np.array(ImageOps.grayscale(img))
210
- blurred_img = cv2.GaussianBlur(gs_img, (13, 13), 1)
211
-
212
- # Calculate the threshold values for double-thresholding
213
- min_g = np.min(blurred_img[:])
214
- max_g = np.max(blurred_img[:])
215
- edge_img = cv2.Canny(
216
- blurred_img, 0.10 * (max_g - min_g) + min_g, 0.30 * (max_g - min_g) + min_g
217
- )
218
-
219
- return Image.fromarray(edge_img)
220
- except BaseException as e:
221
- logging.exception(e)
222
- return None
223
-
224
-
225
- def contentful_value(img):
226
- """
227
- Get the contentful value by counting the number of
228
- defined pixels in the image of the edges.
229
- """
230
- try:
231
- import numpy as np
232
-
233
- edge_img = np.array(edges_im(img))
234
- white_pixels = np.where(edge_img != 0)
235
-
236
- return len(white_pixels[0])
237
- except BaseException as e:
238
- logging.exception(e)
239
- return None
240
-
241
-
242
- def build_edge_video(video_path, viewport):
243
- """Compute, and highlight the edges of a given video.
244
-
245
- Makes use of the same technique as the contentful value
246
- calculation. However, it crops, and scales the image using
247
- our own method rather than FFMPEG. This creates two videos
248
- suffixed with `-edges` and `-edges-overlay` that contain
249
- the raw edges, and a video with the edges overlaid. They will
250
- be found in the same location as the original video.
251
-
252
- These videos will only be produced when --contentful-video is
253
- used.
254
- """
255
- logging.debug("Creating edge video for {0}".format(video_path))
256
-
257
- output_dir, video_name = os.path.split(video_path)
258
- video_name, _ = os.path.splitext(video_name)
259
-
260
- try:
261
- import cv2
262
- import numpy as np
263
- from PIL import Image, ImageOps
264
-
265
- # Get the edges of all frames
266
- edge_video = []
267
- resized_video = []
268
- video = cv2.VideoCapture(video_path)
269
- frame_count = video.get(cv2.CAP_PROP_FPS)
270
- while video.isOpened():
271
- ret, frame = video.read()
272
- if ret:
273
- cropped_im = frame
274
- if viewport:
275
- cropped_im = crop_im(
276
- frame,
277
- viewport["width"],
278
- viewport["height"],
279
- viewport["x"],
280
- viewport["y"],
281
- )
282
- resized_video.append(scale(cropped_im, options.thumbsize))
283
- edge_video.append(np.array(edges_im(resized_video[-1])))
284
- else:
285
- video.release()
286
- break
287
-
288
- out_size = edge_video[-1].shape
289
- out_edges = cv2.VideoWriter(
290
- os.path.join(output_dir, video_name + "-edges.mp4"),
291
- cv2.VideoWriter_fourcc(*"MP4V"),
292
- frame_count,
293
- (out_size[1], out_size[0]),
294
- 1,
295
- )
296
- out_edges_overlay = cv2.VideoWriter(
297
- os.path.join(output_dir, video_name + "-edges-overlay.mp4"),
298
- cv2.VideoWriter_fourcc(*"MP4V"),
299
- frame_count,
300
- (out_size[1], out_size[0]),
301
- 1,
302
- )
303
- for i, frame in enumerate(edge_video):
304
- cframe = np.zeros((out_size[0], out_size[1], 3))
305
- overlayframe = np.array(resized_video[i])
306
- for x in range(cframe.shape[0]):
307
- for y in range(cframe.shape[1]):
308
- if frame[x, y] != 0:
309
- cframe[x, y, :] = (0, 0, 255)
310
- overlayframe[x, y, :] = (0, 0, 255)
311
- out_edges.write(np.uint8(cframe))
312
- out_edges_overlay.write(np.uint8(overlayframe))
313
-
314
- out_edges.release()
315
- out_edges_overlay.release()
316
-
317
- logging.debug("Finished creating edge videos for {0}".format(video_path))
318
- except BaseException as e:
319
- logging.exception(e)
320
- return
321
-
322
-
323
- def convert_to_srgb(img):
324
- """Convert PIL image to sRGB color space (if possible)"""
325
- try:
326
- import io
327
- from PIL import Image, ImageCms
328
-
329
- icc = img.info.get("icc_profile", "")
330
-
331
- if icc:
332
- return ImageCms.profileToProfile(
333
- img,
334
- ImageCms.ImageCmsProfile(io.BytesIO(icc)),
335
- ImageCms.createProfile("sRGB"),
336
- )
337
-
338
- logging.debug(
339
- "Unable to convert image to sRGB as there is no color "
340
- "profile to transform from."
341
- )
342
- return img
343
- except BaseException as e:
344
- logging.exception(e)
345
- return None
346
-
347
-
348
- def convert_img_to_jpeg(src, dest, quality=30):
349
- """Convert an image to a JPEG with the given quality."""
350
- try:
351
- from PIL import Image
352
-
353
- with Image.open(src) as img:
354
- img = convert_to_srgb(img)
355
- img.save(dest, quality=quality)
356
- except BaseException as e:
357
- logging.exception(e)
358
- return
359
-
360
-
361
- # #################################################################################################
362
- # Frame Extraction and de-duplication
363
- # #################################################################################################
364
-
365
-
366
- def video_to_frames(
367
- video,
368
- directory,
369
- force,
370
- orange_file,
371
- find_viewport,
372
- viewport_retries,
373
- viewport_min_height,
374
- viewport_min_width,
375
- full_resolution,
376
- ):
377
- """Extract the video frames"""
378
- global client_viewport
379
- first_frame = os.path.join(directory, "ms_000000")
380
- if (
381
- not os.path.isfile(first_frame + ".png")
382
- and not os.path.isfile(first_frame + ".jpg")
383
- ) or force:
384
- if os.path.isfile(video):
385
- video = os.path.realpath(video)
386
- logging.info("Processing frames from video " + video + " to " + directory)
387
- is_mobile = find_recording_platform(video)
388
- if os.path.isdir(directory):
389
- shutil.rmtree(directory, True)
390
- if not os.path.isdir(directory):
391
- os.mkdir(directory, 0o755)
392
- if os.path.isdir(directory):
393
- directory = os.path.realpath(directory)
394
- viewport, cropped = find_video_viewport(
395
- video,
396
- directory,
397
- find_viewport,
398
- viewport_retries,
399
- viewport_min_height,
400
- viewport_min_width,
401
- is_mobile,
402
- )
403
-
404
- if options.contentful_video:
405
- # Create some videos with the edges
406
- build_edge_video(video, viewport)
407
-
408
- gc.collect()
409
- if extract_frames(video, directory, full_resolution, viewport):
410
- client_viewport = None
411
- directories = [directory]
412
- for dir in directories:
413
- if orange_file is not None:
414
- remove_frames_before_orange(dir, orange_file)
415
- remove_orange_frames(dir, orange_file)
416
- find_render_start(dir, orange_file, cropped, is_mobile)
417
- adjust_frame_times(dir)
418
- eliminate_duplicate_frames(dir, cropped, is_mobile)
419
- crop_viewport(dir)
420
- gc.collect()
421
- else:
422
- logging.critical("Error extracting the video frames from %s", video)
423
- else:
424
- logging.critical("Error creating output directory: %s", directory)
425
- else:
426
- logging.critical("Input video file %s does not exist", video)
427
- else:
428
- logging.info("Extracted video already exists in %s", directory)
429
-
430
-
431
- def extract_frames(video, directory, full_resolution, viewport):
432
- """Extract and number the video frames"""
433
- ret = False
434
- logging.info("Extracting frames from " + video + " to " + directory)
435
- decimate = get_decimate_filter()
436
- if decimate is not None:
437
- crop = ""
438
- if viewport is not None:
439
- crop = "crop={0}:{1}:{2}:{3},".format(
440
- viewport["width"], viewport["height"], viewport["x"], viewport["y"]
441
- )
442
- scale = "scale=iw*min({0:d}/iw\\,{0:d}/ih):ih*min({0:d}/iw\\,{0:d}/ih),".format(
443
- options.thumbsize
444
- )
445
- if full_resolution:
446
- scale = ""
447
- # escape directory name
448
- # see https://en.wikibooks.org/wiki/FFMPEG_An_Intermediate_Guide/image_sequence#Percent_in_filename
449
- dir_escaped = directory.replace("%", "%%")
450
- command = [
451
- "ffmpeg",
452
- "-v",
453
- "debug",
454
- "-i",
455
- video,
456
- "-vsync",
457
- "0",
458
- "-vf",
459
- crop + scale + decimate + "=0:64:640:0.001",
460
- os.path.join(dir_escaped, "img-%d.png"),
461
- ]
462
- logging.debug(" ".join(command))
463
- lines = []
464
-
465
- proc = subprocess.Popen(command, stderr=subprocess.PIPE, encoding="UTF-8")
466
- while proc.poll() is None:
467
- lines.extend(iter(proc.stderr.readline, ""))
468
-
469
- pattern = re.compile(r"keep pts:[0-9]+ pts_time:(?P<timecode>[0-9\.]+)")
470
- frame_count = 0
471
- for line in lines:
472
- match = re.search(pattern, line)
473
- if match:
474
- frame_count += 1
475
- frame_time = int(
476
- math.floor(float(match.groupdict().get("timecode")) * 1000)
477
- )
478
- src = os.path.join(directory, "img-{0:d}.png".format(frame_count))
479
- dest = os.path.join(directory, "video-{0:06d}.png".format(frame_time))
480
- logging.debug("Renaming " + src + " to " + dest)
481
- os.rename(src, dest)
482
- ret = True
483
- return ret
484
-
485
-
486
- def find_recording_platform(video):
487
- """Find the platform that this video was recorded on.
488
-
489
- We can make use of a field called `com.android.version` to
490
- determine if we've recorded on mobile or not.
491
- """
492
- command = ["ffprobe", video]
493
- logging.debug(command)
494
-
495
- lines = []
496
- proc = subprocess.Popen(command, stderr=subprocess.PIPE, encoding="UTF-8")
497
-
498
- while proc.poll() is None:
499
- lines.extend(iter(proc.stderr.readline, ""))
500
-
501
- is_mobile = False
502
- matcher = re.compile(".*com\.android\.version.*")
503
- for line in lines:
504
- if matcher.search(line):
505
- is_mobile = True
506
-
507
- return is_mobile
508
-
509
-
510
- def remove_frames_before_orange(directory, orange_file):
511
- """Remove stray frames from the start of the video"""
512
- frames = sorted(glob.glob(os.path.join(directory, "video-*.png")))
513
- if len(frames):
514
- # go through the first 20 frames and remove any that come before the first orange frame.
515
- # iOS video capture starts with a blank white frame and then flips to
516
- # orange before starting.
517
- logging.debug("Scanning for non-orange frames...")
518
- found_orange = False
519
- remove_frames = []
520
- frame_count = 0
521
- for frame in frames:
522
- frame_count += 1
523
- if is_color_frame(frame, orange_file):
524
- found_orange = True
525
- break
526
- if frame_count > 20:
527
- break
528
- remove_frames.append(frame)
529
-
530
- if found_orange and len(remove_frames):
531
- for frame in remove_frames:
532
- logging.debug("Removing pre-orange frame %s", frame)
533
- os.remove(frame)
534
-
535
-
536
- def remove_orange_frames(directory, orange_file):
537
- """Remove orange frames from the beginning of the video"""
538
- frames = sorted(glob.glob(os.path.join(directory, "video-*.png")))
539
- if len(frames):
540
- logging.debug("Scanning for orange frames...")
541
- for frame in frames:
542
- if is_color_frame(frame, orange_file):
543
- logging.debug("Removing Orange frame: %s", frame)
544
- os.remove(frame)
545
- else:
546
- break
547
- for frame in reversed(frames):
548
- if is_color_frame(frame, orange_file):
549
- logging.debug("Removing orange frame %s from the end", frame)
550
- os.remove(frame)
551
- else:
552
- break
553
-
554
-
555
- def find_image_viewport(file, is_mobile):
556
- logging.debug("Finding the viewport for %s", file)
557
- try:
558
- from PIL import Image
559
-
560
- im = Image.open(file)
561
- width, height = im.size
562
- x = int(math.floor(width / 2))
563
- y = int(math.floor(height / 2))
564
- pixels = im.load()
565
- background = pixels[x, y]
566
-
567
- # Find the left edge
568
- left = None
569
- while left is None and x >= 0:
570
- if not colors_are_similar(background, pixels[x, y]):
571
- left = x + 1
572
- else:
573
- x -= 1
574
- if left is None:
575
- left = 0
576
- logging.debug("Viewport left edge is %d", left)
577
-
578
- # Find the right edge
579
- x = int(math.floor(width / 2))
580
- right = None
581
- while right is None and x < width:
582
- if not colors_are_similar(background, pixels[x, y]):
583
- right = x - 1
584
- else:
585
- x += 1
586
- if right is None:
587
- right = width
588
- logging.debug("Viewport right edge is {0:d}".format(right))
589
-
590
- # Find the top edge
591
- x = int(math.floor(width / 2))
592
- top = None
593
- while top is None and y >= 0:
594
- if not colors_are_similar(background, pixels[x, y]):
595
- top = y + 1
596
- else:
597
- y -= 1
598
- if top is None:
599
- top = 0
600
- logging.debug("Viewport top edge is {0:d}".format(top))
601
-
602
- # Find the bottom edge
603
- y = int(math.floor(height / 2))
604
- bottom = None
605
- while bottom is None and y < height:
606
- if not colors_are_similar(background, pixels[x, y]):
607
- bottom = y - 1
608
- else:
609
- y += 1
610
- if bottom is None:
611
- bottom = height
612
- logging.debug("Viewport bottom edge is {0:d}".format(bottom))
613
-
614
- viewport = {
615
- "x": left,
616
- "y": top,
617
- "width": (right - left),
618
- "height": (bottom - top),
619
- }
620
-
621
- if is_mobile:
622
- # On mobile we need to ignore the top ~10 pixels because
623
- # there is a visible progress bar there on some browsers.
624
- viewport["y"] += 10
625
- viewport["height"] -= 10
626
-
627
- except Exception:
628
- viewport = None
629
-
630
- return viewport
631
-
632
-
633
- def find_video_viewport(
634
- video,
635
- directory,
636
- find_viewport,
637
- viewport_retries,
638
- viewport_min_height,
639
- viewport_min_width,
640
- is_mobile,
641
- ):
642
- logging.debug("Finding Video Viewport...")
643
- viewport = None
644
-
645
- # cropped will be True if the viewport setting changes
646
- # the original frame
647
- cropped = False
648
-
649
- try:
650
- from PIL import Image
651
-
652
- retries = -1
653
-
654
- while (
655
- viewport is None
656
- or viewport["height"] <= viewport_min_height
657
- or viewport["width"] <= viewport_min_width
658
- ):
659
- retries += 1
660
- if retries >= 1:
661
- # In some cases, the first frame is not an orange screen or a screen
662
- # with a solid color. In this case, we need to try finding the viewport
663
- # using the next frame. The `viewport_retries` dictates the maximum number
664
- # of frames to check.
665
- if retries >= viewport_retries:
666
- logging.exception(
667
- "Could not calculate a viewport after %s tries.",
668
- viewport_retries,
669
- )
670
- break
671
- logging.info("Failed to find a good viewport. Retrying...")
672
-
673
- logging.debug("Using frame " + str(retries))
674
-
675
- frame = os.path.join(directory, "viewport.png")
676
- if os.path.isfile(frame):
677
- os.remove(frame)
678
-
679
- command = ["ffmpeg", "-i", video]
680
-
681
- # Pull one frame from the video starting with the frame at
682
- # the `retries` index
683
- command.extend(["-vf", "select=gte(n\\,%s)" % retries])
684
- command.extend(["-frames:v", "1", frame])
685
- subprocess.check_output(command)
686
-
687
- if os.path.isfile(frame):
688
- with Image.open(frame) as im:
689
- width, height = im.size
690
- logging.debug("%s is %dx%d", frame, width, height)
691
- if find_viewport:
692
- viewport = find_image_viewport(frame, is_mobile)
693
- else:
694
- viewport = {"x": 0, "y": 0, "width": width, "height": height}
695
-
696
- os.remove(frame)
697
-
698
- if viewport is not None and viewport != {
699
- "x": 0,
700
- "y": 0,
701
- "width": width,
702
- "height": height,
703
- }:
704
- cropped = True
705
-
706
- except Exception as e:
707
- viewport = None
708
-
709
- return viewport, cropped
710
-
711
-
712
- def adjust_frame_times(directory):
713
- offset = None
714
- frames = sorted(glob.glob(os.path.join(directory, "video-*.png")))
715
- # Special hack to the the video start
716
- # Let us tune this in the future to skip using a global
717
- global videoRecordingStart
718
- match = re.compile(r"video-(?P<ms>[0-9]+)\.png")
719
- if len(frames):
720
- for frame in frames:
721
- m = re.search(match, frame)
722
- if m is not None:
723
- frame_time = int(m.groupdict().get("ms"))
724
- if offset is None:
725
- # This is the first frame.
726
- videoRecordingStart = frame_time
727
- offset = frame_time
728
- new_time = frame_time - offset
729
- dest = os.path.join(directory, "ms_{0:06d}.png".format(new_time))
730
- os.rename(frame, dest)
731
-
732
-
733
- def find_render_start(directory, orange_file, cropped, is_mobile):
734
- logging.debug("Finding Render Start...")
735
- try:
736
- if (
737
- client_viewport is not None
738
- or options.viewport is not None
739
- or (options.renderignore > 0 and options.renderignore <= 100)
740
- ):
741
- files = sorted(glob.glob(os.path.join(directory, "video-*.png")))
742
- count = len(files)
743
- if count > 1:
744
- from PIL import Image
745
-
746
- first = files[0]
747
- with Image.open(first) as im:
748
- width, height = im.size
749
- if options.renderignore > 0 and options.renderignore <= 100:
750
- mask = {}
751
- mask["width"] = int(math.floor(width * options.renderignore / 100))
752
- mask["height"] = int(
753
- math.floor(height * options.renderignore / 100)
754
- )
755
- mask["x"] = int(math.floor(width / 2 - mask["width"] / 2))
756
- mask["y"] = int(math.floor(height / 2 - mask["height"] / 2))
757
- else:
758
- mask = None
759
-
760
- im_width = width
761
- im_height = height
762
-
763
- top = 10
764
- right_margin = 10
765
- bottom_margin = 24
766
- if height > 400 or width > 400:
767
- top = max(top, int(math.ceil(float(height) * 0.03)))
768
- right_margin = max(
769
- right_margin, int(math.ceil(float(width) * 0.04))
770
- )
771
- bottom_margin = max(
772
- bottom_margin, int(math.ceil(float(width) * 0.04))
773
- )
774
- height = max(height - top - bottom_margin, 1)
775
- left = 0
776
- width = max(width - right_margin, 1)
777
- if client_viewport is not None:
778
- height = max(client_viewport["height"] - top - bottom_margin, 1)
779
- width = max(client_viewport["width"] - right_margin, 1)
780
- left += client_viewport["x"]
781
- top += client_viewport["y"]
782
- elif cropped:
783
- # The image was already cropped, so only cutout the bottom
784
- # to get rid of the network request/etc. information for
785
- # desktop videos, and nothing extra on mobile.
786
- top = 0
787
- left = 0
788
- width = im_width
789
-
790
- if is_mobile:
791
- height = im_height
792
- else:
793
- height = im_height - bottom_margin
794
-
795
- crop = (width, height, left, top)
796
-
797
- for i in range(1, count):
798
- if frames_match(first, files[i], 10, 0, crop, mask):
799
- logging.debug("Removing pre-render frame %s", files[i])
800
- os.remove(files[i])
801
- elif orange_file is not None and is_color_frame(
802
- files[i], orange_file
803
- ):
804
- logging.debug("Removing orange frame %s", files[i])
805
- os.remove(files[i])
806
- else:
807
- break
808
- except BaseException:
809
- logging.exception("Error getting render start")
810
-
811
-
812
- def eliminate_duplicate_frames(directory, cropped, is_mobile):
813
- logging.debug("Eliminating Duplicate Frames...")
814
- global client_viewport
815
- try:
816
- files = sorted(glob.glob(os.path.join(directory, "ms_*.png")))
817
- if len(files) > 1:
818
- from PIL import Image
819
-
820
- blank = files[0]
821
- with Image.open(blank) as im:
822
- width, height = im.size
823
- im_width = width
824
- im_height = height
825
-
826
- # Figure out the region of the image that we care about
827
- top = 40
828
- right_margin = 10
829
- bottom_margin = 10
830
- if height > 400 or width > 400:
831
- top = int(math.ceil(float(height) * 0.04))
832
- right_margin = int(math.ceil(float(width) * 0.04))
833
- bottom_margin = int(math.ceil(float(width) * 0.06))
834
- height = max(height - top - bottom_margin, 1)
835
- left = 0
836
- width = max(width - right_margin, 1)
837
-
838
- if client_viewport is not None:
839
- height = max(client_viewport["height"] - top - bottom_margin, 1)
840
- width = max(client_viewport["width"] - right_margin, 1)
841
- left += client_viewport["x"]
842
- top += client_viewport["y"]
843
- elif cropped:
844
- # The image was already cropped, so only cutout the bottom
845
- # to get rid of the network request/etc. information for
846
- # desktop videos, and nothing extra on mobile.
847
- top = 0
848
- left = 0
849
- width = im_width
850
-
851
- if is_mobile:
852
- height = im_height
853
- else:
854
- height = im_height - bottom_margin
855
-
856
- crop = (width, height, left, top)
857
- logging.debug("Viewport cropping set to (W, H, L, T): " + str(crop))
858
-
859
- # Do a pass looking for the first non-blank frame with an allowance
860
- # for up to a 10% per-pixel difference for noise in the white
861
- # field.
862
- count = len(files)
863
- for i in range(1, count):
864
- if frames_match(blank, files[i], 10, 0, crop, None):
865
- logging.debug(
866
- "Removing duplicate frame {0} from the beginning".format(
867
- files[i]
868
- )
869
- )
870
- os.remove(files[i])
871
- else:
872
- break
873
-
874
- # Do another pass looking for the last frame but with an allowance for up
875
- # to a 15% difference in individual pixels to deal with noise
876
- # around text.
877
- files = sorted(glob.glob(os.path.join(directory, "ms_*.png")))
878
- count = len(files)
879
- duplicates = []
880
- if count > 2:
881
- files.reverse()
882
- baseline = files[0]
883
- previous_frame = baseline
884
- for i in range(1, count):
885
- if frames_match(baseline, files[i], 15, 5, crop, None):
886
- if previous_frame is baseline:
887
- duplicates.append(previous_frame)
888
- else:
889
- logging.debug(
890
- "Removing duplicate frame {0} from the end".format(
891
- previous_frame
892
- )
893
- )
894
- os.remove(previous_frame)
895
- previous_frame = files[i]
896
- else:
897
- break
898
- for duplicate in duplicates:
899
- logging.debug(
900
- "Removing duplicate frame {0} from the end".format(duplicate)
901
- )
902
- os.remove(duplicate)
903
-
904
- except BaseException:
905
- logging.exception("Error processing frames for duplicates")
906
-
907
-
908
- def crop_viewport(directory):
909
- if client_viewport is not None:
910
- try:
911
- from PIL import Image
912
-
913
- files = sorted(glob.glob(os.path.join(directory, "ms_*.png")))
914
- count = len(files)
915
- if count > 0:
916
- for i in range(count):
917
- with Image.open(files[i]) as im:
918
- new_img = crop_im(
919
- im,
920
- client_viewport["width"],
921
- client_viewport["height"],
922
- client_viewport["x"],
923
- client_viewport["y"],
924
- )
925
- new_img.save(files[i])
926
-
927
- except BaseException:
928
- logging.exception("Error cropping to viewport")
929
-
930
-
931
- def get_decimate_filter():
932
- decimate = None
933
- try:
934
- filters = subprocess.check_output(
935
- ["ffmpeg", "-filters"], stderr=subprocess.STDOUT, encoding="UTF-8"
936
- )
937
- lines = filters.split("\n")
938
- match = re.compile(
939
- r"(?P<filter>[\w]*decimate).*V->V.*Remove near-duplicate frames"
940
- )
941
- for line in lines:
942
- m = re.search(match, line)
943
- if m is not None:
944
- decimate = m.groupdict().get("filter")
945
- break
946
- except BaseException:
947
- logging.critical("Error checking ffmpeg filters for decimate")
948
- decimate = None
949
- return decimate
950
-
951
-
952
- def clean_directory(directory):
953
- files = glob.glob(os.path.join(directory, "*.png"))
954
- for file in files:
955
- os.remove(file)
956
- files = glob.glob(os.path.join(directory, "*.jpg"))
957
- for file in files:
958
- os.remove(file)
959
- files = glob.glob(os.path.join(directory, "*.json"))
960
- for file in files:
961
- os.remove(file)
962
-
963
-
964
- def is_color_frame(file, color_file):
965
- """Check a section from the middle, top and bottom of the viewport to see if it matches"""
966
- global frame_cache
967
- if file in frame_cache and color_file in frame_cache[file]:
968
- return bool(frame_cache[file][color_file])
969
- match = False
970
- if os.path.isfile(color_file):
971
- try:
972
- from PIL import Image
973
-
974
- with Image.open(file) as img:
975
- width, height = img.size
976
- crops = []
977
-
978
- # Middle
979
- crops.append(
980
- (int(width / 2), int(height / 3), int(width / 4), int(height / 3))
981
- )
982
- # Top
983
- crops.append((int(width / 2), int(height / 5), int(width / 4), 50))
984
- # Bottom
985
- crops.append(
986
- (
987
- int(width / 2),
988
- int(height / 5),
989
- int(width / 4),
990
- height - int(height / 5),
991
- )
992
- )
993
-
994
- for crop in crops:
995
- with Image.open(file) as im:
996
- crop_i = crop_im(im, crop[0], crop[1], crop[2], crop[3])
997
- resized_im = resize(crop_i, 200, 200)
998
-
999
- with Image.open(color_file) as color_im:
1000
- different_pixels = compare(resized_im, color_im, fuzz=0.15)
1001
-
1002
- if different_pixels < 10000:
1003
- match = True
1004
- break
1005
- except Exception as e:
1006
- logging.debug(e)
1007
- pass
1008
- if file not in frame_cache:
1009
- frame_cache[file] = {}
1010
- frame_cache[file][color_file] = bool(match)
1011
- return match
1012
-
1013
-
1014
- def colors_are_similar(a, b, threshold=15):
1015
- similar = True
1016
- sum = 0
1017
- for x in range(3):
1018
- delta = abs(a[x] - b[x])
1019
- sum += delta
1020
- if delta > threshold:
1021
- similar = False
1022
- if sum > threshold:
1023
- similar = False
1024
-
1025
- return similar
1026
-
1027
-
1028
- def frames_match(image1, image2, fuzz_percent, max_differences, crop_region, mask_rect):
1029
- match = False
1030
-
1031
- try:
1032
- from PIL import Image
1033
-
1034
- with Image.open(image1) as i1, Image.open(image2) as i2:
1035
- if mask_rect:
1036
- i1 = mask(
1037
- i1,
1038
- mask_rect["width"],
1039
- mask_rect["height"],
1040
- mask_rect["x"],
1041
- mask_rect["y"],
1042
- )
1043
- i2 = mask(
1044
- i2,
1045
- mask_rect["width"],
1046
- mask_rect["height"],
1047
- mask_rect["x"],
1048
- mask_rect["y"],
1049
- )
1050
-
1051
- if crop_region:
1052
- i1 = crop_im(
1053
- i1, crop_region[0], crop_region[1], crop_region[2], crop_region[3]
1054
- )
1055
- i2 = crop_im(
1056
- i2, crop_region[0], crop_region[1], crop_region[2], crop_region[3]
1057
- )
1058
-
1059
- different_pixels = compare(i1, i2, fuzz=fuzz_percent / 100)
1060
- if different_pixels <= max_differences:
1061
- match = True
1062
-
1063
- except BaseException as e:
1064
- logging.exception(e)
1065
- return None
1066
-
1067
- return match
1068
-
1069
-
1070
- def generate_orange_png(orange_file):
1071
- try:
1072
- from PIL import Image, ImageDraw
1073
-
1074
- im = Image.new("RGB", (200, 200))
1075
- draw = ImageDraw.Draw(im)
1076
- draw.rectangle([0, 0, 200, 200], fill=(222, 100, 13))
1077
- del draw
1078
- im.save(orange_file, "PNG")
1079
- except BaseException:
1080
- logging.exception("Error generating orange png " + orange_file)
1081
-
1082
-
1083
- ##########################################################################
1084
- # Histogram calculations
1085
- ##########################################################################
1086
-
1087
-
1088
- def calculate_histograms(directory, histograms_file, force):
1089
- logging.debug("Calculating image histograms")
1090
- if not os.path.isfile(histograms_file) or force:
1091
- try:
1092
- extension = None
1093
- directory = os.path.realpath(directory)
1094
- first_frame = os.path.join(directory, "ms_000000")
1095
- if os.path.isfile(first_frame + ".png"):
1096
- extension = ".png"
1097
- elif os.path.isfile(first_frame + ".jpg"):
1098
- extension = ".jpg"
1099
- if extension is not None:
1100
- histograms = []
1101
- frames = sorted(glob.glob(os.path.join(directory, "ms_*" + extension)))
1102
- match = re.compile(r"ms_(?P<ms>[0-9]+)\.")
1103
- for frame in frames:
1104
- m = re.search(match, frame)
1105
- if m is not None:
1106
- frame_time = int(m.groupdict().get("ms"))
1107
- histogram = calculate_image_histogram(frame)
1108
- gc.collect()
1109
- if histogram is not None:
1110
- histograms.append(
1111
- {
1112
- "time": frame_time,
1113
- "file": os.path.basename(frame),
1114
- "histogram": histogram,
1115
- }
1116
- )
1117
- if os.path.isfile(histograms_file):
1118
- os.remove(histograms_file)
1119
- f = gzip.open(histograms_file, GZIP_TEXT)
1120
- json.dump(histograms, f)
1121
- f.close()
1122
- else:
1123
- logging.critical("No video frames found in " + directory)
1124
- except BaseException:
1125
- logging.exception("Error calculating histograms")
1126
- else:
1127
- logging.debug("Histograms file {0} already exists".format(histograms_file))
1128
- logging.debug("Done calculating histograms")
1129
-
1130
-
1131
- def calculate_image_histogram(file):
1132
- logging.debug("Calculating histogram for " + file)
1133
- try:
1134
- from PIL import Image
1135
-
1136
- im = Image.open(file)
1137
- width, height = im.size
1138
- colors = im.getcolors(width * height)
1139
- histogram = {
1140
- "r": [0 for i in range(256)],
1141
- "g": [0 for i in range(256)],
1142
- "b": [0 for i in range(256)],
1143
- }
1144
- for entry in colors:
1145
- try:
1146
- count = entry[0]
1147
- pixel = entry[1]
1148
- # Don't include White pixels (with a tiny bit of slop for
1149
- # compression artifacts)
1150
- if pixel[0] < 250 or pixel[1] < 250 or pixel[2] < 250:
1151
- histogram["r"][pixel[0]] += count
1152
- histogram["g"][pixel[1]] += count
1153
- histogram["b"][pixel[2]] += count
1154
- except Exception:
1155
- pass
1156
- colors = None
1157
- except Exception:
1158
- histogram = None
1159
- logging.exception("Error calculating histogram for " + file)
1160
- return histogram
1161
-
1162
-
1163
- ##########################################################################
1164
- # Screen Shots
1165
- ##########################################################################
1166
-
1167
-
1168
- def save_screenshot(directory, dest, quality):
1169
- directory = os.path.realpath(directory)
1170
- files = sorted(glob.glob(os.path.join(directory, "ms_*.png")))
1171
- if files is not None and len(files) >= 1:
1172
- src = files[-1]
1173
- if dest[-4:] == ".jpg":
1174
- convert_img_to_jpeg(src, dest, quality=quality)
1175
- else:
1176
- shutil.copy(src, dest)
1177
-
1178
-
1179
- ##########################################################################
1180
- # JPEG conversion
1181
- ##########################################################################
1182
-
1183
-
1184
- def convert_to_jpeg(directory, quality):
1185
- logging.debug("Converting video frames to JPEG")
1186
- directory = os.path.realpath(directory)
1187
- pattern = os.path.join(directory, "ms_*.png")
1188
-
1189
- files = sorted(glob.glob(pattern))
1190
- for file in files:
1191
- _, filename = os.path.split(file)
1192
- filen, ext = os.path.splitext(filename)
1193
- convert_img_to_jpeg(
1194
- file, os.path.join(directory, filen + ".jpg"), quality=quality
1195
- )
1196
- os.remove(file)
1197
-
1198
- logging.debug("Done converting video frames to JPEG")
1199
-
1200
-
1201
- ##########################################################################
1202
- # Visual Metrics
1203
- ##########################################################################
1204
-
1205
-
1206
- def calculate_visual_metrics(
1207
- histograms_file,
1208
- start,
1209
- end,
1210
- perceptual,
1211
- contentful,
1212
- dirs,
1213
- progress_file,
1214
- hero_elements_file,
1215
- ):
1216
- metrics = None
1217
- histograms = load_histograms(histograms_file, start, end)
1218
- if histograms is not None and len(histograms) > 0:
1219
- progress = calculate_visual_progress(histograms)
1220
- if progress and progress_file is not None:
1221
- file_name, ext = os.path.splitext(progress_file)
1222
- if ext.lower() == ".gz":
1223
- f = gzip.open(progress_file, GZIP_TEXT, 7)
1224
- else:
1225
- f = open(progress_file, "w")
1226
- json.dump(progress, f)
1227
- f.close()
1228
- if len(histograms) > 1:
1229
- metrics = [
1230
- {"name": "First Visual Change", "value": histograms[1]["time"]},
1231
- {"name": "Last Visual Change", "value": histograms[-1]["time"]},
1232
- {"name": "Speed Index", "value": calculate_speed_index(progress)},
1233
- ]
1234
- if perceptual:
1235
- value, value_progress = calculate_perceptual_speed_index(progress, dirs)
1236
- metrics.extend(
1237
- (
1238
- {"name": "Perceptual Speed Index", "value": value},
1239
- {
1240
- "name": "Perceptual Speed Index Progress",
1241
- "value": value_progress,
1242
- },
1243
- )
1244
- )
1245
- if contentful:
1246
- value, value_progress = calculate_contentful_speed_index(progress, dirs)
1247
-
1248
- metrics.extend(
1249
- (
1250
- {"name": "Contentful Speed Index", "value": value},
1251
- {
1252
- "name": "Contentful Speed Index Progress",
1253
- "value": value_progress,
1254
- },
1255
- )
1256
- )
1257
- if hero_elements_file is not None and os.path.isfile(hero_elements_file):
1258
- logging.debug("Calculating hero element times")
1259
- hero_data = None
1260
- hero_f_in = gzip.open(hero_elements_file, GZIP_READ_TEXT)
1261
- try:
1262
- hero_data = json.load(hero_f_in)
1263
- except Exception as e:
1264
- logging.exception("Could not load hero elements data")
1265
- logging.exception(e)
1266
- hero_f_in.close()
1267
-
1268
- if (
1269
- hero_data is not None
1270
- and hero_data["heroes"] is not None
1271
- and hero_data["viewport"] is not None
1272
- and len(hero_data["heroes"]) > 0
1273
- ):
1274
- viewport = hero_data["viewport"]
1275
- hero_timings = []
1276
- for hero in hero_data["heroes"]:
1277
- hero_time = calculate_hero_time(progress, dirs, hero, viewport)
1278
- if hero_time is not None:
1279
- hero_timings.append(
1280
- {
1281
- "name": hero["name"],
1282
- "value": hero_time,
1283
- }
1284
- )
1285
- hero_timings_sorted = sorted(
1286
- hero_timings, key=lambda timing: timing["value"]
1287
- )
1288
- # hero_timings.append({'name': 'FirstPaintedHero',
1289
- # 'value': hero_timings_sorted[0]['value']})
1290
- if (len(hero_timings_sorted) > 0):
1291
- hero_timings.append(
1292
- {
1293
- "name": "LastMeaningfulPaint",
1294
- "value": hero_timings_sorted[-1]["value"],
1295
- }
1296
- )
1297
- hero_data["timings"] = hero_timings
1298
- metrics += hero_timings
1299
-
1300
- hero_f_out = gzip.open(hero_elements_file, GZIP_TEXT, 7)
1301
- json.dump(hero_data, hero_f_out)
1302
- hero_f_out.close()
1303
- else:
1304
- logging.warn(
1305
- "Hero elements file is not valid: " + str(hero_elements_file)
1306
- )
1307
- else:
1308
- metrics = [
1309
- {"name": "First Visual Change", "value": histograms[0]["time"]},
1310
- {"name": "Last Visual Change", "value": histograms[0]["time"]},
1311
- {"name": "Visually Complete", "value": histograms[0]["time"]},
1312
- {"name": "Speed Index", "value": 0},
1313
- ]
1314
- if perceptual:
1315
- metrics.append({"name": "Perceptual Speed Index", "value": 0})
1316
- if contentful:
1317
- metrics.append({"name": "Contentful Speed Index", "value": 0})
1318
- prog = ""
1319
- for p in progress:
1320
- if len(prog):
1321
- prog += ", "
1322
- prog += "{0:d}={1:d}".format(p["time"], int(p["progress"]))
1323
- metrics.append({"name": "Visual Progress", "value": prog})
1324
-
1325
- return metrics
1326
-
1327
-
1328
- def load_histograms(histograms_file, start, end):
1329
- histograms = None
1330
- if os.path.isfile(histograms_file):
1331
- f = gzip.open(histograms_file)
1332
- original = json.load(f)
1333
- f.close()
1334
- if start != 0 or end != 0:
1335
- histograms = []
1336
- for histogram in original:
1337
- if histogram["time"] <= start:
1338
- histogram["time"] = start
1339
- histograms = [histogram]
1340
- elif histogram["time"] <= end:
1341
- histograms.append(histogram)
1342
- else:
1343
- break
1344
- else:
1345
- histograms = original
1346
- return histograms
1347
-
1348
-
1349
- def calculate_visual_progress(histograms):
1350
- progress = []
1351
- first = histograms[0]["histogram"]
1352
- last = histograms[-1]["histogram"]
1353
- for index, histogram in enumerate(histograms):
1354
- p = calculate_frame_progress(histogram["histogram"], first, last)
1355
- file_name, ext = os.path.splitext(histogram["file"])
1356
- progress.append({"time": histogram["time"], "file": file_name, "progress": p})
1357
- logging.debug("{0:d}ms - {1:d}% Complete".format(histogram["time"], int(p)))
1358
- return progress
1359
-
1360
-
1361
- def calculate_frame_progress(histogram, start, final):
1362
- """Calculate the progress percentage of a given frame histogram.
1363
-
1364
- This method finds the visually-complete progress by taking a sum of
1365
- all the differences in the histogram between the current frame, and the
1366
- final frame. The initial/first frame is used to remove values that are
1367
- consitent between the first, and final frame (i.e. it's a baseline).
1368
-
1369
- Note that this method should not be using a slop/fuzz because we aren't
1370
- looking at individual pixel intensities which is where the fuzz can be
1371
- found. Within individual channels, we hit an issue where we cannot tell
1372
- if a value in the red channel is purely from a red colour in the image, or
1373
- if it's mixed with other colours such as grey (e.g. red with rbg(240,0,0),
1374
- and light grey with rgb(245,245,245)).
1375
- """
1376
- total = 0
1377
- matched = 0
1378
- for channel in ("r", "g", "b"):
1379
- for pixel_ind in range(256):
1380
- curr = histogram[channel][pixel_ind]
1381
- init = start[channel][pixel_ind]
1382
- target = final[channel][pixel_ind]
1383
-
1384
- curr_diff = abs(curr - init)
1385
- target_diff = abs(target - init)
1386
-
1387
- matched += min(curr_diff, target_diff)
1388
- total += target_diff
1389
-
1390
- progress = (float(matched) / float(total)) if total else 1
1391
- return math.floor(progress * 100)
1392
-
1393
-
1394
- def find_visually_complete(progress):
1395
- time = 0
1396
- for p in progress:
1397
- if int(p["progress"]) == 100:
1398
- time = p["time"]
1399
- break
1400
- elif time == 0:
1401
- time = p["time"]
1402
- return time
1403
-
1404
-
1405
- def calculate_speed_index(progress):
1406
- si = 0
1407
- last_ms = progress[0]["time"]
1408
- last_progress = progress[0]["progress"]
1409
- for p in progress:
1410
- elapsed = p["time"] - last_ms
1411
- si += elapsed * (1.0 - last_progress)
1412
- last_ms = p["time"]
1413
- last_progress = p["progress"] / 100.0
1414
- return int(si)
1415
-
1416
-
1417
- def calculate_contentful_speed_index(progress, directory):
1418
- # convert output comes out with lines that have this format:
1419
- # <pixel count>: <rgb color> #<hex color> <gray color>
1420
- # This is CLI dependant and very fragile
1421
- matcher = re.compile(r"(\d+?):")
1422
-
1423
- try:
1424
- from PIL import Image
1425
-
1426
- dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), directory)
1427
- content = []
1428
- maxContent = 0
1429
- for p in progress[1:]:
1430
- # Full Path of the Current Frame
1431
- current_frame = os.path.join(dir, "ms_{0:06d}.png".format(p["time"]))
1432
- logging.debug("contentfulSpeedIndex: Current frame is %s" % current_frame)
1433
-
1434
- value = 0
1435
- with Image.open(current_frame) as current_frame_img:
1436
- value = contentful_value(current_frame_img)
1437
-
1438
- logging.debug("contentfulSpeedIndex: Contentful value {0}".format(value))
1439
-
1440
- if value > maxContent:
1441
- maxContent = value
1442
- content.append(value)
1443
-
1444
- for i, value in enumerate(content):
1445
- if maxContent > 0:
1446
- content[i] = float(content[i]) / float(maxContent)
1447
- else:
1448
- content[i] = 0.0
1449
-
1450
- # Assume 0 content for first frame
1451
- cont_si = 1 * (progress[1]["time"] - progress[0]["time"])
1452
- completeness_value = [(progress[1]["time"], int(cont_si))]
1453
- for i in range(1, len(progress) - 1):
1454
- elapsed = progress[i + 1]["time"] - progress[i]["time"]
1455
- # print i,' time =',p['time'],'elapsed =',elapsed,'content = ',content[i]
1456
- cont_si += elapsed * (1.0 - content[i])
1457
- completeness_value.append((progress[i + 1]["time"], int(cont_si)))
1458
-
1459
- cont_si = int(cont_si)
1460
- raw_progress_value = ["0=0"]
1461
- for timestamp, percent in completeness_value:
1462
- p = int(100 * float(percent) / float(cont_si))
1463
- raw_progress_value.append("%d=%d" % (timestamp, p))
1464
-
1465
- return cont_si, ", ".join(raw_progress_value)
1466
- except Exception as e:
1467
- logging.exception(e)
1468
- return None, None
1469
-
1470
-
1471
- def calculate_perceptual_speed_index(progress, directory):
1472
- from ssim import compute_ssim
1473
-
1474
- x = len(progress)
1475
- dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), directory)
1476
- first_paint_frame = os.path.join(dir, "ms_{0:06d}.png".format(progress[1]["time"]))
1477
- target_frame = os.path.join(dir, "ms_{0:06d}.png".format(progress[x - 1]["time"]))
1478
- ssim_1 = compute_ssim(first_paint_frame, target_frame)
1479
- per_si = float(progress[1]["time"])
1480
- last_ms = progress[1]["time"]
1481
- # Full Path of the Target Frame
1482
- logging.debug("Target image for perSI is %s" % target_frame)
1483
- ssim = ssim_1
1484
- completeness_value = []
1485
- for p in progress[1:]:
1486
- elapsed = p["time"] - last_ms
1487
- # print '*******elapsed %f'%elapsed
1488
- # Full Path of the Current Frame
1489
- current_frame = os.path.join(dir, "ms_{0:06d}.png".format(p["time"]))
1490
- logging.debug("Current Image is %s" % current_frame)
1491
- # Takes full path of PNG frames to compute SSIM value
1492
- per_si += elapsed * (1.0 - ssim)
1493
- ssim = compute_ssim(current_frame, target_frame)
1494
- gc.collect()
1495
- last_ms = p["time"]
1496
- completeness_value.append((p["time"], int(per_si)))
1497
-
1498
- per_si = int(per_si)
1499
- raw_progress_value = ["0=0"]
1500
- for timestamp, percent in completeness_value:
1501
- p = int(100 * float(percent) / float(per_si))
1502
- raw_progress_value.append("%d=%d" % (timestamp, p))
1503
-
1504
- return per_si, ", ".join(raw_progress_value)
1505
-
1506
-
1507
- def calculate_hero_time(progress, directory, hero, viewport):
1508
- try:
1509
- dir = os.path.join(os.path.dirname(os.path.abspath(__file__)), directory)
1510
- n = len(progress)
1511
- target_frame = os.path.join(dir, "ms_{0:06d}".format(progress[n - 1]["time"]))
1512
-
1513
- extension = None
1514
- if os.path.isfile(target_frame + ".png"):
1515
- extension = ".png"
1516
- elif os.path.isfile(target_frame + ".jpg"):
1517
- extension = ".jpg"
1518
- if extension is not None:
1519
- hero_width = int(hero["width"])
1520
- hero_height = int(hero["height"])
1521
- hero_x = int(hero["x"])
1522
- hero_y = int(hero["y"])
1523
- target_frame = target_frame + extension
1524
- logging.debug(
1525
- "Target image for hero %s is %s" % (hero["name"], target_frame)
1526
- )
1527
-
1528
- from PIL import Image
1529
-
1530
- with Image.open(target_frame) as im:
1531
- width, height = im.size
1532
- if width != viewport["width"]:
1533
- scale = float(width) / float(viewport["width"])
1534
- logging.debug(
1535
- "Frames are %dpx wide but viewport was %dpx. Scaling by %f"
1536
- % (width, viewport["width"], scale)
1537
- )
1538
- hero_width = int(hero["width"] * scale)
1539
- hero_height = int(hero["height"] * scale)
1540
- hero_x = int(hero["x"] * scale)
1541
- hero_y = int(hero["y"] * scale)
1542
-
1543
- logging.debug(
1544
- 'Calculating render time for hero element "%s" at position [%d, %d, %d, %d]'
1545
- % (hero["name"], hero_x, hero_y, hero_width, hero_height)
1546
- )
1547
-
1548
- # Apply the mask to the target frame to create the reference frame
1549
- target_mask_path = os.path.join(
1550
- dir,
1551
- "hero_{0}_ms_{1:06d}.png".format(hero["name"], progress[n - 1]["time"]),
1552
- )
1553
-
1554
- def __apply_hero_mask(cur_frame):
1555
- """Helper method for re-applying the same mask."""
1556
- cropped_frame = None
1557
- with Image.open(cur_frame) as im:
1558
- cropped_frame = crop_im(im, hero_width, hero_height, hero_x, hero_y)
1559
- return cropped_frame
1560
-
1561
- target_mask = __apply_hero_mask(target_frame)
1562
- target_mask.save(target_mask_path)
1563
-
1564
- def cleanup():
1565
- if os.path.isfile(target_mask_path):
1566
- os.remove(target_mask_path)
1567
-
1568
- # Allow for small differences like scrollbars and overlaid UI elements
1569
- # by applying a 10% fuzz and allowing for up to 2% of the pixels to be
1570
- # different.
1571
- fuzz = 10
1572
- max_pixel_diff = math.ceil(hero_width * hero_height * 0.02)
1573
-
1574
- for p in progress:
1575
- current_frame = os.path.join(dir, "ms_{0:06d}".format(p["time"]))
1576
- extension = None
1577
- if os.path.isfile(current_frame + ".png"):
1578
- extension = ".png"
1579
- elif os.path.isfile(current_frame + ".jpg"):
1580
- extension = ".jpg"
1581
- if extension is not None:
1582
- current_mask_path = os.path.join(
1583
- dir, "hero_{0}_ms_{1:06d}.png".format(hero["name"], p["time"])
1584
- )
1585
-
1586
- current_mask = __apply_hero_mask(current_frame + extension)
1587
- current_mask.save(current_mask_path)
1588
-
1589
- match = frames_match(
1590
- target_mask_path,
1591
- current_mask_path,
1592
- fuzz,
1593
- max_pixel_diff,
1594
- None,
1595
- None,
1596
- )
1597
-
1598
- # Remove each mask after using it
1599
- os.remove(current_mask_path)
1600
-
1601
- if match:
1602
- # Clean up masks as soon as a match is found
1603
- cleanup()
1604
- return p["time"]
1605
-
1606
- # No matches found; clean up masks
1607
- cleanup()
1608
-
1609
- return None
1610
- except Exception as e:
1611
- logging.exception(e)
1612
- return None
1613
-
1614
-
1615
- ##########################################################################
1616
- # Check any dependencies
1617
- ##########################################################################
1618
-
1619
-
1620
- def check_config():
1621
- ok = True
1622
-
1623
- if get_decimate_filter() is not None:
1624
- logging.debug("FFMPEG found")
1625
- else:
1626
- print("ffmpeg: FAIL")
1627
- ok = False
1628
-
1629
- if sys.version_info >= (3, 6):
1630
- logging.debug("Python 3.6+ found")
1631
- else:
1632
- print("Python 3.6+: FAIL")
1633
- ok = False
1634
-
1635
- try:
1636
- import numpy as np
1637
-
1638
- logging.debug("Numpy found")
1639
- except BaseException:
1640
- print("Numpy: FAIL")
1641
- ok = False
1642
-
1643
- try:
1644
- import cv2
1645
-
1646
- logging.debug("OpenCV-Python found")
1647
- except BaseException:
1648
- print("OpenCV-Python: FAIL")
1649
- ok = False
1650
-
1651
- try:
1652
- from PIL import Image, ImageCms, ImageDraw, ImageOps # noqa
1653
-
1654
- logging.debug("Pillow found")
1655
- except BaseException:
1656
- print("Pillow: FAIL")
1657
- ok = False
1658
-
1659
- try:
1660
- from ssim import compute_ssim # noqa
1661
-
1662
- logging.debug("SSIM found")
1663
- except BaseException:
1664
- print("SSIM: FAIL")
1665
- ok = False
1666
-
1667
- return ok
1668
-
1669
-
1670
- def check_process(command, output):
1671
- ok = False
1672
- try:
1673
- out = subprocess.check_output(
1674
- command, stderr=subprocess.STDOUT, shell=True, encoding="UTF-8"
1675
- )
1676
- if out.find(output) > -1:
1677
- ok = True
1678
- except BaseException:
1679
- ok = False
1680
- return ok
1681
-
1682
-
1683
- ##########################################################################
1684
- # Main Entry Point
1685
- ##########################################################################
1686
-
1687
-
1688
- def main():
1689
- import argparse
1690
-
1691
- global options
1692
-
1693
- parser = argparse.ArgumentParser(
1694
- description="Calculate visual performance metrics from a video.",
1695
- prog="visualmetrics",
1696
- )
1697
- parser.add_argument("--version", action="version", version="%(prog)s 0.1")
1698
- parser.add_argument(
1699
- "-c",
1700
- "--check",
1701
- action="store_true",
1702
- default=False,
1703
- help="Check dependencies (ffmpeg, Numpy, OpenCV-Python, PIL, SSIM).",
1704
- )
1705
- parser.add_argument(
1706
- "-v",
1707
- "--verbose",
1708
- action="count",
1709
- help="Increase verbosity (specify multiple times for more).",
1710
- )
1711
- parser.add_argument(
1712
- "--logfile", help="Write log messages to given file instead of stdout"
1713
- )
1714
- parser.add_argument(
1715
- "--logformat",
1716
- help="Formatting for the log messages",
1717
- default="%(asctime)s.%(msecs)03d - %(message)s",
1718
- )
1719
- parser.add_argument("-i", "--video", help="Input video file.")
1720
- parser.add_argument(
1721
- "-d",
1722
- "--dir",
1723
- help="Directory of video frames "
1724
- "(as input if exists or as output if a video file is specified).",
1725
- )
1726
- parser.add_argument(
1727
- "--render", help="Render the video frames to the given mp4 video file."
1728
- )
1729
- parser.add_argument(
1730
- "--screenshot",
1731
- help="Save the last frame of video as an image to the path provided.",
1732
- )
1733
- parser.add_argument(
1734
- "-q",
1735
- "--quality",
1736
- type=int,
1737
- help="JPEG Quality " "(if specified, frames will be converted to JPEG).",
1738
- )
1739
- parser.add_argument(
1740
- "-l",
1741
- "--full",
1742
- action="store_true",
1743
- default=False,
1744
- help="Keep full-resolution images instead of resizing to 400x400 pixels",
1745
- )
1746
- parser.add_argument(
1747
- "--thumbsize", type=int, default=400, help="Thumbnail size (defaults to 400)."
1748
- )
1749
- parser.add_argument(
1750
- "-f",
1751
- "--force",
1752
- action="store_true",
1753
- default=False,
1754
- help="Force processing of a video file (overwrite existing directory).",
1755
- )
1756
- parser.add_argument(
1757
- "-o",
1758
- "--orange",
1759
- action="store_true",
1760
- default=False,
1761
- help="Remove orange-colored frames from the beginning of the video.",
1762
- )
1763
- parser.add_argument(
1764
- "-p",
1765
- "--viewport",
1766
- action="store_true",
1767
- default=False,
1768
- help="Locate and use the viewport from the first video frame.",
1769
- )
1770
- parser.add_argument(
1771
- "--viewportretries",
1772
- type=int,
1773
- default=5,
1774
- help="Number of times to attempt to obtain a viewport. Analagous to the "
1775
- "number of frames to try to find a viewport with. By default, up to the "
1776
- "first 5 frames are used.",
1777
- )
1778
- parser.add_argument(
1779
- "--viewportminheight",
1780
- type=int,
1781
- default=0,
1782
- help="The minimum possible height (in pixels) for the viewport. Used when "
1783
- "attempting to find the viewport size. Defaults to 0.",
1784
- )
1785
- parser.add_argument(
1786
- "--viewportminwidth",
1787
- type=int,
1788
- default=0,
1789
- help="The minimum possible width (in pixels) for the viewport. Used when "
1790
- "attempting to find the viewport size. Defaults to 0.",
1791
- )
1792
- parser.add_argument(
1793
- "-s",
1794
- "--start",
1795
- type=int,
1796
- default=0,
1797
- help="Start time (in milliseconds) for calculating visual metrics.",
1798
- )
1799
- parser.add_argument(
1800
- "-e",
1801
- "--end",
1802
- type=int,
1803
- default=0,
1804
- help="End time (in milliseconds) for calculating visual metrics.",
1805
- )
1806
- parser.add_argument(
1807
- "--renderignore",
1808
- type=int,
1809
- default=0,
1810
- help="Ignore the center X%% of the frame when looking for "
1811
- "the first rendered frame (useful for Opera mini).",
1812
- )
1813
- parser.add_argument(
1814
- "-k",
1815
- "--perceptual",
1816
- action="store_true",
1817
- default=False,
1818
- help="Calculate perceptual Speed Index",
1819
- )
1820
- parser.add_argument(
1821
- "--contentful",
1822
- action="store_true",
1823
- default=False,
1824
- help="Calculate contentful Speed Index",
1825
- )
1826
- parser.add_argument(
1827
- "--contentful-video",
1828
- action="store_true",
1829
- default=False,
1830
- help="Produce a video of the edges used in the ContentfulSpeedIndex "
1831
- "calculation. The resulting videos are suffixed with -edge and "
1832
- "-edge-overlay.",
1833
- )
1834
- parser.add_argument(
1835
- "-j",
1836
- "--json",
1837
- action="store_true",
1838
- default=False,
1839
- help="Set output format to JSON",
1840
- )
1841
- parser.add_argument("--progress", help="Visual progress output file.")
1842
- parser.add_argument("--herodata", help="Hero elements data file.")
1843
-
1844
- options = parser.parse_args()
1845
-
1846
- if not options.check and not options.dir and not options.video:
1847
- parser.error(
1848
- "A video, Directory of images or histograms file needs to be provided.\n\n"
1849
- "Use -h to see available options"
1850
- )
1851
-
1852
- if options.perceptual or options.contentful:
1853
- if not options.video:
1854
- parser.error(
1855
- "A video file needs to be provided.\n\n"
1856
- "Use -h to see available options"
1857
- )
1858
-
1859
- temp_dir = tempfile.mkdtemp(prefix="vis-")
1860
- colors_temp_dir = tempfile.mkdtemp(prefix="vis-color-")
1861
- directory = temp_dir
1862
- if options.dir is not None:
1863
- directory = options.dir
1864
- histogram_file = os.path.join(temp_dir, "histograms.json.gz")
1865
-
1866
- # Set up logging
1867
- log_level = logging.CRITICAL
1868
- if options.verbose == 1:
1869
- log_level = logging.ERROR
1870
- elif options.verbose == 2:
1871
- log_level = logging.WARNING
1872
- elif options.verbose == 3:
1873
- log_level = logging.INFO
1874
- elif options.verbose == 4:
1875
- log_level = logging.DEBUG
1876
- if options.logfile is not None:
1877
- logging.basicConfig(
1878
- filename=options.logfile,
1879
- level=log_level,
1880
- format=options.logformat,
1881
- datefmt="%H:%M:%S",
1882
- )
1883
- else:
1884
- logging.basicConfig(
1885
- level=log_level, format=options.logformat, datefmt="%H:%M:%S"
1886
- )
1887
-
1888
- ok = False
1889
- try:
1890
- if not options.check:
1891
- # Run a quick check to make sure all requirements exist,
1892
- # otherwise failures might be silent due to how this code is
1893
- # structured.
1894
- ok = check_config()
1895
- if not ok:
1896
- raise Exception("Please install requirements before running.")
1897
-
1898
- if options.video:
1899
- orange_file = None
1900
- if options.orange:
1901
- orange_file = os.path.join(
1902
- os.path.dirname(os.path.realpath(__file__)), "orange.png"
1903
- )
1904
- if not os.path.isfile(orange_file):
1905
- orange_file = os.path.join(colors_temp_dir, "orange.png")
1906
- generate_orange_png(orange_file)
1907
- video_to_frames(
1908
- options.video,
1909
- directory,
1910
- options.force,
1911
- orange_file,
1912
- options.viewport,
1913
- options.viewportretries,
1914
- options.viewportminheight,
1915
- options.viewportminwidth,
1916
- options.full,
1917
- )
1918
-
1919
- # Calculate the histograms and visual metrics
1920
- calculate_histograms(directory, histogram_file, options.force)
1921
- metrics = calculate_visual_metrics(
1922
- histogram_file,
1923
- options.start,
1924
- options.end,
1925
- options.perceptual,
1926
- options.contentful,
1927
- directory,
1928
- options.progress,
1929
- options.herodata,
1930
- )
1931
-
1932
- if options.screenshot is not None:
1933
- quality = 30
1934
- if options.quality is not None:
1935
- quality = options.quality
1936
- save_screenshot(directory, options.screenshot, quality)
1937
- # JPEG conversion
1938
- if options.dir is not None and options.quality is not None:
1939
- convert_to_jpeg(directory, options.quality)
1940
-
1941
- if metrics is not None:
1942
- ok = True
1943
- if options.json:
1944
- data = dict()
1945
- for metric in metrics:
1946
- data[metric["name"].replace(" ", "")] = metric["value"]
1947
- if "videoRecordingStart" in globals():
1948
- data["videoRecordingStart"] = videoRecordingStart
1949
- print(json.dumps(data))
1950
- else:
1951
- for metric in metrics:
1952
- print("{0}: {1}".format(metric["name"], metric["value"]))
1953
- else:
1954
- ok = check_config()
1955
- except Exception as e:
1956
- logging.exception(e)
1957
- ok = False
1958
-
1959
- # Clean up
1960
- shutil.rmtree(temp_dir)
1961
- shutil.rmtree(colors_temp_dir)
1962
- if ok:
1963
- exit(0)
1964
- else:
1965
- exit(1)
1966
-
1967
-
1968
- if "__main__" == __name__:
1969
- main()