mishig/so101-painting / scripts /edit_comparison.py
mishig's picture
download
raw
13.7 kB
"""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.