diff --git a/.gitignore b/.gitignore index a7109fe..9bc30ee 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,30 @@ __pycache__/* modules/__pycache__/* waveshare_lib/build/* waveshare_lib/dist/* +custom-conf.conf +debug.log +mbridge-changelog.md +test_app.py +test_frame.py +.idea/.gitignore +.idea/misc.xml +.idea/modules.xml +.idea/vcs.xml +.idea/vsmp-plus.iml +.idea/inspectionProfiles/profiles_settings.xml +.idea/inspectionProfiles/Project_Default.xml +web/static/images/original-splash.jpg +.idea/.gitignore +.idea/misc.xml +.idea/modules.xml +.idea/vcs.xml +.idea/vsmp-plus.iml +.idea/inspectionProfiles/profiles_settings.xml +.idea/inspectionProfiles/Project_Default.xml +mbridge-changelog.md +custom-conf.conf +omni-epd.ini +test_app.py +web/static/images/original-splash.jpg +web/static/images/splash.png +test_frame.py diff --git a/CHANGELOG.md b/CHANGELOG.md index f85654d..1ecb085 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,30 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) +## 2025-07-02 + +### Fixed +- Pos now reset when starting a new file + +### Added +- Image enhancement using PIL contrast & brightness settings for better +- Full screen images without black bars +- Startup messages now appear on RHS of screen, image on LHS +- QR Code on RHS of startup screen for config URL + +## 2025-07-02 + +### Fixed +- Skipping blank frames did not advance. Changed logic of main loop in update_display and use new function to get ffmpeg to find the next non-black frame. + +## 2025-05-31 + +### Fixed + +- Seek fails due to config variable not being set (moved db read of config to beginning of execute_action in webapp.py +- Fix to seek commands being ignored (caused by data being over-written by update_display()) +- Fix hour value in seek tooltip + ## 2025-05-28 ### Fixed diff --git a/modules/utils.py b/modules/utils.py index 963fc02..23e57ec 100644 --- a/modules/utils.py +++ b/modules/utils.py @@ -70,7 +70,7 @@ def frames_to_seconds(frames, fps): def seconds_to_frames(seconds, fps): - return int(seconds) * fps + return int(seconds * fps) # Check if a file is a video diff --git a/modules/webapp.py b/modules/webapp.py index 2f34aa8..b772486 100644 --- a/modules/webapp.py +++ b/modules/webapp.py @@ -74,6 +74,7 @@ def save_configuration(): @app.route('/api/control/', methods=['POST']) def execute_action(action): result = {'success': True, 'message': '', 'action': action} + config = utils.get_configuration(db) # get the action if(action in ['pause', 'resume']): @@ -81,7 +82,6 @@ def execute_action(action): utils.write_db(db, utils.DB_PLAYER_STATUS, {'running': action == 'resume'}) # eval to True/False result['message'] = f"Action {action} executed" elif(action in ['next', 'prev']): - config = utils.get_configuration(db) if(config['mode'] == 'dir'): nextFile = utils.read_db(db, utils.DB_LAST_PLAYED_FILE) @@ -89,9 +89,9 @@ def execute_action(action): # use the info parser to find the next or previous file if(config['media'] == 'video'): - info = VideoInfo(utils.get_configuration(db)) + info = VideoInfo(config) else: - info = ImageInfo(utils.get_configuration(db)) + info = ImageInfo(config) # load how many skips are queued skip_num = utils.read_db(db, utils.DB_IMAGE_SKIP) @@ -133,7 +133,7 @@ def execute_action(action): if('file' in nextVideo): # see to this value - info = VideoInfo(utils.get_configuration(db)) + info = VideoInfo(config) nextVideo = info.seek_video(nextVideo, data['amount']) result['message'] = f"Seeking to {data['amount']:.2f} percent on next update" diff --git a/omni-epd.ini b/omni-epd.ini index a158558..1408e5f 100644 --- a/omni-epd.ini +++ b/omni-epd.ini @@ -1,3 +1,11 @@ [omni_epd.mock] mode=color file=tmp/screenshot.png + +[Display] +#dither=simple2d +dither=FloydSteinberg +#dither=FalseFloydSteinberg +dither_strength=1.0 +dither_serpentine=True + diff --git a/vsmp.py b/vsmp.py index da9d505..cfb2fe9 100755 --- a/vsmp.py +++ b/vsmp.py @@ -2,6 +2,8 @@ import ffmpeg import logging import os +import fcntl, struct +import re import modules.utils as utils from modules.videoinfo import ImageInfo, VideoInfo from modules.twinepd import TwinEpd @@ -12,11 +14,13 @@ import redis import socket import shutil +import qrcode import modules.webapp as webapp from omni_epd import displayfactory from croniter import croniter from datetime import datetime, timedelta -from PIL import Image, ImageDraw, ImageFont +from PIL import Image, ImageDraw, ImageFont, ImageOps, ImageEnhance, ImageStat + # create the tmp directory if it doesn't exist if (not os.path.exists(utils.TMP_DIR)): @@ -60,16 +64,68 @@ def changed_ip_check(config, db): config['display'].append('ip') utils.write_db(db, utils.DB_CONFIGURATION, config) +def find_next_non_black_frame(video_path, start_time, num_frames, fps): + # This ffmpeg method is unreliable for low num_frames (e.g. 2), + # Use fps as a minimum value + + num_frames = max(num_frames, int(fps)) + # Find the end of a black segment after a given time + try: + out, err = ( + ffmpeg + .input(video_path, ss=start_time) + .output('pipe:', vf='blackdetect=d=0.1:pic_th=0.98', format='null', vframes=num_frames, an=None) + .global_args('-hide_banner') + .run(capture_stdout=True, capture_stderr=True) + ) + + pattern = re.compile(r'black_start:(\d+(?:\.\d+)?)\s+black_end:(\d+(?:\.\d+)?)') + + for line in err.decode().splitlines(): + match = pattern.search(line) + if match: + return float(match.group(1)), float(match.group(2)) + except ffmpeg.Error as e: + logging.error(f"Blank frame detection FFmpeg error: {e.stderr.decode()}") + + return None def generate_frame(in_filename, out_filename, time): try: - ffmpeg.input(in_filename, ss=time) \ - .filter('scale', 'iw*sar', 'ih') \ - .filter('scale', width, height, force_original_aspect_ratio=1) \ - .filter('pad', width, height, -1, -1) \ - .output(out_filename, vframes=1) \ - .overwrite_output() \ - .run(capture_stdout=True, capture_stderr=True) + scale_method = 3 + match scale_method: + case 1: + # Original + ffmpeg.input(in_filename, ss=time) \ + .filter('scale', 'iw*sar', 'ih') \ + .filter('scale', width, height, force_original_aspect_ratio=1) \ + .filter('pad', width, height, -1, -1) \ + .output(out_filename, vframes=1) \ + .overwrite_output() \ + .run(capture_stdout=True, capture_stderr=True) + case 2: + # Don't pad to add the black bars until after enhancement + ffmpeg.input(in_filename, ss=time) \ + .filter('scale', 'iw*sar', 'ih') \ + .filter('scale', width, height, force_original_aspect_ratio=1) \ + .output(out_filename, vframes=1) \ + .overwrite_output() \ + .run(capture_stdout=True, capture_stderr=True) + case 3: + # Crop images to fill display + screen_aspect_ratio = f'{width}/{height}' + scale_filter = ( + f"scale='if(gt(a,{screen_aspect_ratio}),-1,{width})':" + f"'if(gt(a,{screen_aspect_ratio}),{height},-1)'" + ) + crop_filter = f"crop={width}:{height}" + full_filter = f"{scale_filter},{crop_filter}" + ffmpeg.input(in_filename, ss=time) \ + .output(out_filename, vframes=1, vf=full_filter) \ + .overwrite_output() \ + .run(capture_stdout=True, capture_stderr=True) + + except ffmpeg.Error as e: logging.error(e.stderr.decode('utf-8')) @@ -124,38 +180,114 @@ def find_next_file(config, lastPlayed, next=False): return result +def enhance_image(img): + # EXPERIMENTAL + # Apply auto contract & brightness to output image + MIN_MEAN_BRIGHT = 0 + MAX_MEAN_BRIGHT = 190 + TARGET_BRIGHTNESS = 128 # Target mean brightness (e.g., 128 for mid-gray) + + stat = ImageStat.Stat(img.convert("L")) + logging.debug(f"1: Frame Mean Brightness = {stat.mean[0]:6.2f}") + logging.debug(f"1: Contrast = {stat.stddev[0]:6.2f}") + + # Auto contrast + img = ImageOps.autocontrast(img) + + # Calculate mean brightness + stat = ImageStat.Stat(img.convert("L")) + mean_brightness = stat.mean[0] + + logging.debug(f"2: Auto contrast -> Brightness = {mean_brightness:6.2f}") + logging.debug(f"2: Contrast = {stat.stddev[0]:6.2f}") + + if MIN_MEAN_BRIGHT < mean_brightness < MAX_MEAN_BRIGHT: + target_brightness = TARGET_BRIGHTNESS + # target_brightness = min(TARGET_BRIGHTNESS, 3 * mean_brightness) + + # Don't overbrighten dark images + adjustment_factor = min(3, target_brightness / mean_brightness) + + logging.debug(f"3: Adjust brightness Target = {target_brightness:6.2f}") + logging.debug(f"3: Adjust brightness, Factor = {adjustment_factor:6.2f}") + + enhancer = ImageEnhance.Brightness(img) + img = enhancer.enhance(adjustment_factor) + + stat = ImageStat.Stat(img.convert("L")) + logging.debug(f"3: Adjust brightness -> Brightness = {stat.mean[0]:6.2f}") + else: + logging.debug(f"3: (Brightness Unchanged)") + + return img + + +def pad_image(img, target_size, color=0): + # TODO: Incorporate this in enhance_image? + + # Pad given Pillow Image to the target_size (width, height), centering it. + target_width, target_height = target_size + original_width, original_height = img.size + + result = Image.new(img.mode, target_size, color) + + # Center the original image on the new canvas + x = (target_width - original_width) // 2 + y = (target_height - original_height) // 2 + result.paste(img, (x, y)) + + return result + + def show_startup(epd, db, messages=[]): epd.prepare() # display startup message on the display font30 = ImageFont.truetype(utils.FONT_PATH, 30) - font24 = ImageFont.truetype(utils.FONT_PATH, 24) + font_normal = ImageFont.truetype(utils.FONT_PATH, 18) current_ip = utils.read_db(db, utils.CURRENT_IP) - messages.append(f"Configure at http://{current_ip['ip']}:{args.port}") + config_url = f"http://{current_ip['ip']}:{args.port}/setup" + messages.append(f"Go to {config_url}") # load a background image - splash_image = os.path.join(utils.DIR_PATH, "web", "static", "images", "splash.jpg") + splash_image = os.path.join(utils.DIR_PATH, "web", "static", "images", "splash.png") background_image = Image.open(splash_image).resize((width, height)) draw = ImageDraw.Draw(background_image) - # calculate the size of the text we're going to draw + # Calculate the size of the text we're going to draw title = "VSMP+" left, top, right, bottom = draw.textbbox((0, 0), text=title, font=font30) tw, th = right - left, bottom - top - draw.text(((width - tw) / 2, (height - th) / 4), title, font=font30, fill=0) + # Center on RH Half of image + draw.text((3 * width / 4 - tw / 2, (height - th) / 4), title, font=font30, fill=0) offset = th * 1.5 # initial offset is height of title plus spacer for m in messages: - left, top, right, bottom = draw.textbbox((0, 0), text=m, font=font24) + left, top, right, bottom = draw.textbbox((0, 0), text=m, font=font_normal) mw, mh = right - left, bottom - top - draw.text(((width - mw) / 2, (height - mh) / 4 + offset), m, font=font24, fill=0) + draw.text((3 * width / 4 - mw / 2, (height - mh) / 4 + offset), m, font=font_normal, fill=0) offset = offset + (th * 1.5) - epd.display(background_image) + # Create QR code instance + qr = qrcode.QRCode( + version=1, # Controls size (1 to 40), 1 is smallest + error_correction=qrcode.constants.ERROR_CORRECT_M, # L, M, Q, H (higher = more robust, bigger QR) + box_size=6, # Pixel size of each "box" + border=4 # Thickness of the border (minimum is 4) + ) + qr.add_data(config_url) + qr.make(fit=True) + + # Generate the image + qr_img = qr.make_image(fill_color="black", back_color="white") + qw, qh = qr_img.size + background_image.paste(qr_img, (3 * width // 4 - qw // 2, height // 2)) + + epd.display(background_image) epd.sleep() @@ -163,6 +295,9 @@ def update_display(config, epd, db): # get the file to display media_file = find_next_file(config, utils.read_db(db, utils.DB_LAST_PLAYED_FILE)) + # Save pos to check for db changes later + last_pos = media_file['pos'] + last_file = media_file['file'] # check if we have a properly analyzed file if('file' not in media_file): @@ -176,9 +311,6 @@ def update_display(config, epd, db): return - # Initialize the screen - epd.prepare() - # save grab file in memory as a bitmap pil_im = None grabFile = os.path.join('/dev/shm/', 'frame.bmp') @@ -191,38 +323,88 @@ def update_display(config, epd, db): pil_im = Image.open(grabFile).resize((width, height)) else: - validImg = False + logging.info(f"Loading {media_file['file']}") + + if(media_file['pos'] >= media_file['info']['frame_count']): + # set 'next' to true to force new video file + media_file = find_next_file(config, utils.read_db(db, utils.DB_LAST_PLAYED_FILE), True) + # Reset pos to start + media_file['pos'] = config['start'] * media_file['info']['fps'] + + # calculate the percent percent_complete + media_file['percent_complete'] = (media_file['pos'] / media_file['info']['frame_count']) * 100 + + # set the position we want to use + frame = media_file['pos'] + + # Convert that frame to ms from start of video (frame/fps) * 1000 + msTimecode = f"{utils.frames_to_seconds(frame, media_file['info']['fps']) * 1000}ms" + + # Use ffmpeg to extract a frame from the movie, crop it, letterbox it and save it in memory + generate_frame(media_file['file'], grabFile, msTimecode) + + # Open grab.jpg in PIL + pil_im = Image.open(grabFile) + + # image not valid if skipping blank (None == blank) + if config['skip_blank'] and pil_im.getbbox() is None: + + logging.info('Image is all black, skipping to next good frame') + + while True: + + # Get next non-blank frame after timecode within next frame increment + # Use ffmpeg to find the next non-blank instead of looping a fetch for each frame + # Could just get next frame increment, but ffmpeg allows use to search for the end of the blank sequence + blanks = find_next_non_black_frame(media_file['file'], msTimecode, config['increment'], media_file['info']['fps']) - while(not validImg): - logging.info(f"Loading {media_file['file']}") + if blanks is None: + break + else: + # We're on a blank frame + blank_start, blank_end = blanks - if(media_file['pos'] >= media_file['info']['frame_count']): - # set 'next' to true to force new video file - media_file = find_next_file(config, utils.read_db(db, utils.DB_LAST_PLAYED_FILE), True) + secsTimecode = float(msTimecode.rstrip("ms")) / 1000 + frame = secsTimecode * media_file['info']['fps'] - # calculate the percent percent_complete - media_file['percent_complete'] = (media_file['pos'] / media_file['info']['frame_count']) * 100 + next_non_blank_frame = frame + utils.seconds_to_frames(blank_end, media_file['info']['fps']) + + logging.info(f"Black frame skip to {next_non_blank_frame}") - # set the position we want to use - frame = media_file['pos'] + if next_non_blank_frame < media_file['info']['frame_count']: + # Jump to end of blank period + frame = next_non_blank_frame + media_file['pos'] = frame + # else all the remaining frames are blank and the increment will take care of moving + # to next file (dir mode) or reset to beginning (file mode): + + # Recalculate msTimecode again if we skipped some blank frames + msTimecode = f"{utils.frames_to_seconds(frame, media_file['info']['fps']) * 1000}ms" - # Convert that frame to ms from start of video (frame/fps) * 1000 - msTimecode = f"{utils.frames_to_seconds(frame, media_file['info']['fps']) * 1000}ms" + # Generate new frame at end of black section + generate_frame(media_file['file'], grabFile, msTimecode) - # Use ffmpeg to extract a frame from the movie, crop it, letterbox it and save it in memory - generate_frame(media_file['file'], grabFile, msTimecode) + # Fetch new image in PIL + pil_im = Image.open(grabFile) - # save the next position - media_file['pos'] = media_file['pos'] + float(config['increment']) + w, h = pil_im.size + logging.debug(f"Image size: {w} px, Height: {h} px") + pil_im = enhance_image(pil_im) - # Open grab.jpg in PIL - pil_im = Image.open(grabFile) + # Pad image with black bars to screen size if needed + # This is so that the enhancement doesn't include the black bars + pil_im = pad_image(pil_im, (width, height), color=(0, 0, 0)) - # image not valid if skipping blank (None == blank) - validImg = (not config['skip_blank']) or (pil_im.getbbox() is not None and config['skip_blank']) + # save the next position + media_file['pos'] = media_file['pos'] + float(config['increment']) + + # If the current values in the database have changed since the start of update_display, + # then a change has been (such as a seek or an api/db update). So we do not write back the calculated + # values for the next run + media_file_now = find_next_file(config, utils.read_db(db, utils.DB_LAST_PLAYED_FILE)) + if media_file_now['pos'] == last_pos: + utils.write_db(db, utils.DB_LAST_PLAYED_FILE, media_file) - if(not validImg): - logging.info('Image is all black, try again') if(len(config['display']) > 0): font18 = ImageFont.truetype(utils.FONT_PATH, 18) @@ -256,28 +438,39 @@ def update_display(config, epd, db): left, top, right, bottom = draw.textbbox((0, 0), text=message, font=font18) tw, th = right - left, bottom - top # gets the width and height of the text drawn # draw timecode, centering on the middle - draw.text(((width - tw) / 2, height - th), message, font=font18, fill=(255, 255, 255)) - # display the image + # Black background rectangle behind text for visibility + x1, y1 = (width - tw) / 2, height - th + x2, y2 = (width + tw) / 2, height + draw.rectangle([(x1-2, y1-2), (x2+2, y2+2)], fill=(0, 0, 0)) + draw.text((x1, y1), message, font=font18, fill=(255, 255, 255)) + + # Initialize the screen & display the image + epd.prepare() + logging.debug("Updating EPD") epd.display(pil_im) + epd.sleep() if(config['media'] == 'video'): # do some calculations for the next run secondsOfVideo = utils.frames_to_seconds(frame, media_file['info']['fps']) - logging.info(f"Diplaying frame {frame} ({secondsOfVideo} seconds) of {media_file['name']}") + logging.info(f"Displaying frame {frame} ({secondsOfVideo} seconds) of {media_file['name']}") if(media_file['pos'] >= media_file['info']['frame_count']): # set 'next' to True to force new file media_file = find_next_file(config, utils.read_db(db, utils.DB_LAST_PLAYED_FILE), True) + # Reset pos to start + media_file['pos'] = config['start'] * media_file['info']['fps'] + + # MBRIDGE - shouldn't we reset the pos to the beginning? + # REMOVE - not needed bc of commit 1916043 branch + # media_file['pos'] = config['start'] * media_file['info']['fps'] + # utils.write_db(db, utils.DB_LAST_PLAYED_FILE, media_file) + logging.info(f"Will start {media_file} on next run") else: logging.info(f"Displaying {media_file['name']}") - # save the last played info - utils.write_db(db, utils.DB_LAST_PLAYED_FILE, media_file) - - epd.sleep() - # parse the arguments parser = configargparse.ArgumentParser(description='VSMP Settings') @@ -368,7 +561,7 @@ def update_display(config, epd, db): # show the startup screen for 1 min before proceeding if(config['startup_screen']): logging.info("Showing startup screen") - show_startup(epd, db, [f"Next Update: {nextUpdate.strftime('%m/%d at %H:%M')}"]) + show_startup(epd, db, [f"Next Update: {nextUpdate.strftime('%d/%m/%Y at %H:%M')}"]) time.sleep(60) # reset cronitor to current time @@ -390,6 +583,7 @@ def update_display(config, epd, db): # check if the display should be updated pStatus = utils.read_db(db, utils.DB_PLAYER_STATUS) if(nextUpdate <= now): + if(pStatus['running']): update_display(config, epd, db) utils.write_db(db, utils.DB_LAST_RUN, {'last_run': now.timestamp()}) diff --git a/web/static/images/splash.png b/web/static/images/splash.png new file mode 100644 index 0000000..10055ae Binary files /dev/null and b/web/static/images/splash.png differ diff --git a/web/templates/index.html b/web/templates/index.html index bbfaa71..0c44a07 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -88,9 +88,10 @@ pos = percent * vInfo.frame_count; //turn pos into seconds/minutes/hours of video - seconds = ((pos/vInfo.fps) % 60) % 60; - minutes = ((pos/vInfo.fps)/60) % 60; - hours = (pos/vInfo.fps)/60/60; + totalSeconds = pos/vInfo.fps + hours = Math.floor(totalSeconds / 3600); + minutes = Math.floor((totalSeconds % 3600) / 60); + seconds = Math.round(totalSeconds % 60); //return hours.min of video return hours.toLocaleString('en-US', localeOptions) + ":" +