Buckets:
| """Compare four complete recorded painting attempts in one 30-second video. | |
| Each attempt uses its entire action sequence, sped up uniformly, followed by a | |
| brief 1x final view. The last six seconds compare final camera-derived canvases. | |
| No paint textures, manufactured intermediate frames, or evaluator scores are used. | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import bisect | |
| import json | |
| import math | |
| from pathlib import Path | |
| import shutil | |
| import subprocess | |
| import cv2 | |
| import numpy as np | |
| from PIL import Image, ImageDraw | |
| import edit_video as layout | |
| ROOT = Path(__file__).resolve().parents[1] | |
| FPS = 30 | |
| PER_ATTEMPT_FRAMES = 180 | |
| COMPARISON_FRAMES = 180 | |
| TOTAL_FRAMES = 4 * PER_ATTEMPT_FRAMES + COMPARISON_FRAMES | |
| DEFAULT_LABELS = ['Initial timing', 'Remove endpoint pauses', 'More paint / slower sweep', 'Lighter tower pressure'] | |
| FRAME_BYTES = 960 * 720 * 3 | |
| def plan_attempt(run_dir, number, change): | |
| timeline = json.loads((run_dir / 'timeline.json').read_text()) | |
| source_duration = float(timeline['duration_seconds']) | |
| events = sorted(timeline['events'], key=lambda e: float(e['time_seconds'])) | |
| finished_events = [e for e in events if e.get('type') == 'camera' and e.get('label') == 'finished'] | |
| if not finished_events: | |
| raise ValueError(f'{run_dir}: no finished camera marker; use completed attempts for this comparison.') | |
| finished = float(finished_events[-1]['time_seconds']) | |
| if not 0 < finished < source_duration: | |
| raise ValueError(f'{run_dir}: invalid finished timestamp.') | |
| speed = max(1, math.ceil(finished / 5.5)) | |
| moving_frames = min(165, max(1, round(finished / speed * FPS))) | |
| final_frames = PER_ATTEMPT_FRAMES - moving_frames | |
| final_duration = final_frames / FPS | |
| final_source_end = min(source_duration, finished + final_duration) | |
| messages = [dict(time_seconds=0., title='Preparing the brush', | |
| detail='Load paint and locate the paper in the camera.')] | |
| def first_event(*words): | |
| return next((float(e['time_seconds']) for e in events if e.get('type') == 'move' | |
| and any(word in e.get('label', '').lower() for word in words)), None) | |
| tower = first_event('tower', 'eiffel') | |
| water = first_event('bank', 'seine', 'river') | |
| if tower is not None: | |
| messages.append(dict(time_seconds=tower / speed, | |
| title='Painting the Eiffel Tower', | |
| detail='Trace the tower and add the surrounding details.')) | |
| if water is not None: | |
| messages.append(dict(time_seconds=water / speed, title='Painting the Seine', | |
| detail='Add the riverbanks and flowing water.')) | |
| messages.append(dict(time_seconds=moving_frames / FPS, title='Attempt finished', | |
| detail='The recorded camera view after the brush is lifted.')) | |
| messages.sort(key=lambda message: message['time_seconds']) | |
| return dict(number=number, change=change, source_duration_seconds=source_duration, | |
| playback_speed=speed, moving_frames=moving_frames, frame_count=PER_ATTEMPT_FRAMES, | |
| final_frame_hold_seconds=max(0., final_duration - (final_source_end - finished)), | |
| segments=[dict(source_start=0., source_end=finished, speed=speed, output_frames=moving_frames), | |
| dict(source_start=finished, source_end=final_source_end, speed=1, output_frames=final_frames)], | |
| messages=messages) | |
| def attempt_layout(plan, final=False): | |
| speed_label = '1× SPEED' if final else f'{plan["playback_speed"]}× SPEED' | |
| if final and plan['final_frame_hold_seconds'] > 1 / FPS: | |
| speed_label += ' · FINAL HOLD' | |
| image = layout.render_layout(speed_label) | |
| draw = ImageDraw.Draw(image) | |
| draw.rectangle((20, 15, 1250, 77), fill=layout.BG) | |
| title = f'Attempt {plan["number"]:02d} · {plan["change"]}' | |
| draw.text((32, 30), title, font=layout.font(28, True), fill=layout.WHITE) | |
| return image | |
| def final_rgb(ffmpeg, source): | |
| result = subprocess.run([ffmpeg, '-hide_banner', '-loglevel', 'error', '-sseof', '-0.15', | |
| '-i', str(source), '-vf', 'scale=960:720', | |
| '-f', 'rawvideo', '-pix_fmt', 'rgb24', 'pipe:1'], | |
| stdout=subprocess.PIPE, stderr=subprocess.PIPE, check=True) | |
| if len(result.stdout) < FRAME_BYTES: | |
| raise RuntimeError(f'Could not decode the final camera image from {source}') | |
| return np.frombuffer(result.stdout[-FRAME_BYTES:], dtype=np.uint8).reshape(720, 960, 3).copy() | |
| def comparison_frame(images, plans, selected=None): | |
| image = Image.new('RGB', (1920, 1080), layout.BG) | |
| draw = ImageDraw.Draw(image) | |
| draw.text((32, 24), 'Four attempts · final camera views', font=layout.font(34, True), fill=layout.WHITE) | |
| label = 'SIMPLIFIED WET-PAINT MODEL' | |
| draw.text((1888 - draw.textlength(label, font=layout.font(18, True)), 35), | |
| label, font=layout.font(18, True), fill=layout.SECONDARY) | |
| for index, (canvas, plan) in enumerate(zip(images, plans)): | |
| x = 32 + (index % 2) * 944 | |
| y = 100 + (index // 2) * 470 | |
| draw.rounded_rectangle((x, y, x + 911, y + 443), radius=17, | |
| fill=layout.PANEL_BG, outline=layout.BORDER, width=2) | |
| image.paste(Image.fromarray(canvas).resize((416, 416), Image.Resampling.LANCZOS), (x + 16, y + 14)) | |
| draw = ImageDraw.Draw(image) | |
| draw.text((x + 466, y + 43), f'Attempt {plan["number"]:02d}', font=layout.font(33, True), fill=layout.WHITE) | |
| layout.text_block(draw, plan['change'], x + 466, y + 115, 408, 31, | |
| color=layout.SECONDARY, bold=True, line_height=40, max_lines=4) | |
| if selected == plan['number']: | |
| draw.text((x + 466, y + 313), 'SELECTED RESULT', font=layout.font(20, True), fill=layout.MINT) | |
| draw.text((x + 466, y + 379), 'Final RGB camera', font=layout.font(22), fill=layout.SECONDARY) | |
| draw.text((32, 1039), 'Same scripted picture. Each canvas is rectified from its run’s final front-camera frame.', | |
| font=layout.font(21), fill=layout.SECONDARY) | |
| return image | |
| def main(): | |
| parser = argparse.ArgumentParser(description=__doc__) | |
| parser.add_argument('--run-dirs', type=Path, nargs=4, | |
| default=[ROOT / f'results/attempt-{i:02d}' for i in range(1, 5)]) | |
| parser.add_argument('--labels', nargs=4, default=DEFAULT_LABELS, | |
| help='Four concise, factual descriptions of the changes between attempts.') | |
| parser.add_argument('--output', type=Path, default=ROOT / 'results/comparison.mp4') | |
| parser.add_argument('--selected', type=int, choices=(1, 2, 3, 4), | |
| help='Optional explicitly selected result; no winner is inferred automatically.') | |
| parser.add_argument('--plan-only', action='store_true') | |
| args = parser.parse_args() | |
| plans = [plan_attempt(run_dir, i + 1, label) | |
| for i, (run_dir, label) in enumerate(zip(args.run_dirs, args.labels))] | |
| scratch = ROOT / 'work/video/comparison' | |
| scratch.mkdir(parents=True, exist_ok=True) | |
| manifest = dict(duration_seconds=30., width=1920, height=1080, fps=FPS, | |
| frame_count=TOTAL_FRAMES, attempts=plans, selected_attempt=args.selected, | |
| final_comparison_seconds=6., | |
| source='Full action sequences and rectified final images from recorded RGB camera videos.') | |
| (scratch / 'edit-manifest.json').write_text(json.dumps(manifest, indent=2) + '\n') | |
| print('Comparison: four 6-second runs + 6-second camera comparison = 30 seconds.', flush=True) | |
| print('Action speeds:', ', '.join(f'{p["number"]:02d}: {p["playback_speed"]}×' for p in plans), flush=True) | |
| if args.plan_only: | |
| return | |
| ffmpeg = shutil.which('ffmpeg') | |
| if not ffmpeg: | |
| raise RuntimeError('ffmpeg is required.') | |
| homographies = [np.asarray(json.loads((run_dir / 'camera-localization.json').read_text())['camera_to_canvas'], dtype=float) | |
| for run_dir in args.run_dirs] | |
| sources = [run_dir / 'painting-run.mp4' for run_dir in args.run_dirs] | |
| for source, homography in zip(sources, homographies): | |
| if not source.is_file(): | |
| raise FileNotFoundError(source) | |
| if homography.shape != (3, 3) or not np.isfinite(homography).all(): | |
| raise ValueError('Every run must have a finite 3×3 camera homography.') | |
| canvases = [] | |
| for i, (source, homography) in enumerate(zip(sources, homographies)): | |
| rgb = final_rgb(ffmpeg, source) | |
| canvas = cv2.warpPerspective(rgb, homography, (512, 512), flags=cv2.INTER_CUBIC) | |
| Image.fromarray(rgb).save(scratch / f'attempt-{i+1:02d}-final-camera.png') | |
| Image.fromarray(canvas).save(scratch / f'attempt-{i+1:02d}-final-canvas.png') | |
| canvases.append(canvas) | |
| comparison = comparison_frame(canvases, plans, args.selected) | |
| comparison.save(scratch / 'comparison-preview.png') | |
| args.output.parent.mkdir(parents=True, exist_ok=True) | |
| temporary = args.output.with_name(args.output.stem + '.rendering.mp4') | |
| encoder = decoder = None | |
| blank = Image.new('RGB', (layout.CAPTION_RECT[2] - layout.CAPTION_RECT[0], | |
| layout.CAPTION_RECT[3] - layout.CAPTION_RECT[1]), layout.PANEL_BG) | |
| with (scratch / 'encoder.log').open('w') as encode_log: | |
| try: | |
| encoder = subprocess.Popen([ffmpeg, '-hide_banner', '-loglevel', 'error', '-y', | |
| '-f', 'rawvideo', '-pixel_format', 'rgb24', '-video_size', '1920x1080', '-framerate', str(FPS), | |
| '-i', 'pipe:0', '-an', '-c:v', 'libx264', '-preset', 'fast', '-crf', '14', | |
| '-pix_fmt', 'yuv420p', '-movflags', '+faststart', '-frames:v', str(TOTAL_FRAMES), str(temporary)], | |
| stdin=subprocess.PIPE, stderr=encode_log) | |
| for plan, source, homography in zip(plans, sources, homographies): | |
| filters = ['[0:v]split=2[s0][s1]'] | |
| for i, segment in enumerate(plan['segments']): | |
| filters.append(f'[s{i}]trim=start={segment["source_start"]:.9f}:end={segment["source_end"]:.9f},' | |
| f'setpts=(PTS-STARTPTS)/{segment["speed"]},fps={FPS},' | |
| f'tpad=stop_mode=clone:stop_duration=6,trim=end_frame={segment["output_frames"]},' | |
| f'setpts=PTS-STARTPTS[v{i}]') | |
| filters.append('[v0][v1]concat=n=2:v=1:a=0,scale=960:720,format=rgb24[out]') | |
| panels = [layout.render_panel(message) for message in plan['messages']] | |
| times = [message['time_seconds'] for message in plan['messages']] | |
| backgrounds = [attempt_layout(plan), attempt_layout(plan, final=True)] | |
| with (scratch / f'decoder-{plan["number"]:02d}.log').open('w') as decode_log: | |
| decoder = subprocess.Popen([ffmpeg, '-hide_banner', '-loglevel', 'error', '-i', str(source), | |
| '-filter_complex', ';'.join(filters), '-map', '[out]', '-frames:v', str(PER_ATTEMPT_FRAMES), | |
| '-f', 'rawvideo', '-pix_fmt', 'rgb24', 'pipe:1'], stdout=subprocess.PIPE, stderr=decode_log) | |
| for index in range(PER_ATTEMPT_FRAMES): | |
| raw = decoder.stdout.read(FRAME_BYTES) | |
| if len(raw) != FRAME_BYTES: | |
| raise RuntimeError(f'Attempt {plan["number"]} decoder stopped at frame {index}.') | |
| rgb = np.frombuffer(raw, dtype=np.uint8).reshape(720, 960, 3) | |
| t = index / FPS | |
| active = max(0, bisect.bisect_right(times, t) - 1) | |
| panel = panels[active] | |
| elapsed = t - times[active] | |
| if active and elapsed < .20: | |
| panel = panel.copy() | |
| if elapsed < .08: | |
| caption = Image.blend(panels[active - 1].crop(layout.CAPTION_RECT), blank, elapsed / .08) | |
| else: | |
| caption = Image.blend(blank, panels[active].crop(layout.CAPTION_RECT), (elapsed - .08) / .12) | |
| panel.paste(caption, layout.CAPTION_RECT[:2]) | |
| frame = layout.compose_frame(rgb, backgrounds[index >= plan['moving_frames']], panel, homography) | |
| encoder.stdin.write(frame.tobytes()) | |
| if index == 80: | |
| frame.save(scratch / f'attempt-{plan["number"]:02d}-action-preview.png') | |
| decoder.stdout.close() | |
| if decoder.wait(timeout=60): | |
| raise RuntimeError(f'Attempt {plan["number"]} decoder failed.') | |
| decoder = None | |
| print(f'Rendered attempt {plan["number"]}/4.', flush=True) | |
| final_bytes = comparison.tobytes() | |
| for _ in range(COMPARISON_FRAMES): | |
| encoder.stdin.write(final_bytes) | |
| encoder.stdin.close() | |
| if encoder.wait(timeout=60): | |
| raise RuntimeError('Comparison encoding failed.') | |
| temporary.replace(args.output) | |
| print(f'Saved {args.output}: 30s, 1920×1080, 30 fps.', flush=True) | |
| except BaseException: | |
| for process in (decoder, encoder): | |
| if process is not None and process.poll() is None: | |
| process.kill() | |
| process.wait() | |
| temporary.unlink(missing_ok=True) | |
| raise | |
| if __name__ == '__main__': | |
| main() | |
Xet Storage Details
- Size:
- 13.7 kB
- Xet hash:
- 8b3b770f9b729ddf34fff274c8bca69fc0fd289fa96a57a94cd42701b46ef91f
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.