diff --git a/main.py b/main.py index 774b464..ca33a44 100644 --- a/main.py +++ b/main.py @@ -335,6 +335,9 @@ def extract_column_strip(video_path, x_column, output_path, change_threshold=0.0 # Convert list to numpy array strip_image = np.stack(significant_columns, axis=1) + # Flip horizontally so time flows from right to left (strip photography convention) + strip_image = cv2.flip(strip_image, 1) + # Add timeline overlay if requested if timeline: strip_image = add_timeline_overlay(strip_image, significant_frame_numbers) @@ -460,7 +463,10 @@ def extract_row_strip(video_path, y_row, output_path, change_threshold=0.01, rel # Rotate clockwise 90 degrees for row mode strip_image = cv2.rotate(strip_image, cv2.ROTATE_90_COUNTERCLOCKWISE) - # Add timeline overlay if requested (after rotation) + # Flip horizontally so time flows from right to left (strip photography convention) + strip_image = cv2.flip(strip_image, 1) + + # Add timeline overlay if requested (after rotation and flip) if timeline: strip_image = add_timeline_overlay(strip_image, significant_frame_numbers) @@ -650,13 +656,16 @@ def extract_column_strip_video(video_path, x_column, output_path, change_thresho # Convert to numpy array and create the frame strip_frame = np.stack(accumulated_columns, axis=1) + # Flip horizontally so time flows from right to left (strip photography convention) + strip_frame = cv2.flip(strip_frame, 1) + # Pad the frame to match the final video dimensions current_height, current_width = strip_frame.shape[:2] if current_width < final_output_width or current_height < final_output_height: # Create a black frame of the final size padded_frame = np.zeros((final_output_height, final_output_width, 3), dtype=strip_frame.dtype) - # Copy the current frame to the left side (for progressive width growth) - padded_frame[:current_height, :current_width] = strip_frame + # Copy the current frame to the right side (for progressive width growth from right to left) + padded_frame[:current_height, final_output_width-current_width:] = strip_frame strip_frame = padded_frame # Add timestamp overlay if requested (after padding) @@ -816,13 +825,16 @@ def extract_row_strip_video(video_path, y_row, output_path, change_threshold=0.0 # Rotate counter-clockwise 90 degrees to match image mode orientation strip_frame = cv2.rotate(strip_frame, cv2.ROTATE_90_COUNTERCLOCKWISE) + # Flip horizontally so time flows from right to left (strip photography convention) + strip_frame = cv2.flip(strip_frame, 1) + # Pad the frame to match the final video dimensions current_height, current_width = strip_frame.shape[:2] if current_width < final_output_width or current_height < final_output_height: # Create a black frame of the final size padded_frame = np.zeros((final_output_height, final_output_width, 3), dtype=strip_frame.dtype) - # Copy the current frame to the left side (for progressive width growth) - padded_frame[:current_height, :current_width] = strip_frame + # Copy the current frame to the right side (for progressive width growth from right to left) + padded_frame[:current_height, final_output_width-current_width:] = strip_frame strip_frame = padded_frame # Add timestamp overlay if requested (after padding) @@ -843,6 +855,334 @@ def extract_row_strip_video(video_path, y_row, output_path, change_threshold=0.0 print(f"Total duration: {len(significant_rows)/fps:.2f} seconds") +def extract_column_strip_alpha(video_path, x_column, output_path, change_threshold=0.005, relax=0, start_frame=0, end_frame=None, fps=30, timestamp=False): + """ + Extract vertical strip at x_column from each frame and create PNG sequence with alpha transparency. + Each frame shows the accumulated scan lines up to that point with transparent background. + + Args: + video_path: Path to input video file + x_column: X-coordinate of the column to extract + output_path: Path for output directory (PNG sequence) + change_threshold: Minimum change threshold (0-1) to include frame + relax: Number of extra frames to include before/after threshold frames + start_frame: First frame to process (0-based) + end_frame: Last frame to process (None = until end) + fps: Output video frame rate (for reference) + timestamp: If True, embed frame count on bottom left corner + """ + cap = cv2.VideoCapture(str(video_path)) + + if not cap.isOpened(): + raise ValueError(f"Could not open video file: {video_path}") + + # Get video properties + total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) + frame_width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) + frame_height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + + if x_column >= frame_width: + raise ValueError(f"Column {x_column} is outside video width ({frame_width})") + + # Set end frame if not specified + if end_frame is None: + end_frame = total_frames - 1 + + print(f"Processing frames {start_frame} to {end_frame} ({end_frame - start_frame + 1} frames)...") + print(f"Extracting column {x_column} from {frame_width}x{frame_height} frames") + print(f"Change threshold: {change_threshold}") + if relax > 0: + print(f"Relax: including {relax} frames before/after threshold frames") + + # First pass: collect all columns and identify significant frames + all_columns = [] + changes = [] + frame_numbers = [] + previous_column = None + + frame_idx = 0 + while True: + ret, frame = cap.read() + if not ret: + break + + # Skip frames before start + if frame_idx < start_frame: + frame_idx += 1 + continue + + # Stop after end frame + if frame_idx > end_frame: + break + + # Extract current column + current_column = frame[:, x_column, :].copy() + all_columns.append(current_column) + frame_numbers.append(frame_idx) + + # Calculate change from previous frame + if previous_column is not None: + change = calculate_line_difference(current_column, previous_column) + changes.append(change) + else: + changes.append(0) # First frame has no change + + previous_column = current_column + frame_idx += 1 + + if (frame_idx - start_frame) % 100 == 0: + print(f"Processed {frame_idx - start_frame}/{end_frame - start_frame + 1} frames") + + cap.release() + + # Second pass: determine which frames to include + include_mask = [False] * len(all_columns) + + for i, change in enumerate(changes): + if i == 0 or change >= change_threshold: + # Mark this frame and surrounding frames + start = max(0, i - relax) + end = min(len(all_columns), i + relax + 1) + for j in range(start, end): + include_mask[j] = True + + # Collect significant columns + significant_columns = [] + significant_frame_numbers = [] + for i, col in enumerate(all_columns): + if include_mask[i]: + significant_columns.append(col) + significant_frame_numbers.append(frame_numbers[i]) + + included_frames = sum(include_mask) + skipped_frames = len(all_columns) - included_frames + + if not significant_columns: + raise ValueError("No significant changes detected. Try lowering the threshold.") + + print(f"Original frames in segment: {len(all_columns)}") + print(f"Included frames: {included_frames}") + print(f"Skipped frames: {skipped_frames}") + print(f"Compression ratio: {skipped_frames/len(all_columns):.1%}") + + # Create output directory + # For column mode: width = number of significant frames, height = input frame height + final_output_width = len(significant_columns) + final_output_height = frame_height + + output_dir = Path(output_path) + output_dir.mkdir(parents=True, exist_ok=True) + + print(f"Output PNG sequence dimensions: {final_output_width}x{final_output_height}") + print(f"Creating PNG sequence at {fps} FPS reference: {output_dir}") + + # Generate PNG frames - each frame shows accumulated scan lines up to that point + for frame_idx in range(len(significant_columns)): + # Create accumulated strip image up to current frame + accumulated_columns = significant_columns[:frame_idx + 1] + + # Convert to numpy array and create the frame with alpha channel + strip_frame_bgr = np.stack(accumulated_columns, axis=1) + + # Flip horizontally so time flows from right to left (strip photography convention) + strip_frame_bgr = cv2.flip(strip_frame_bgr, 1) + + # Create BGRA frame with alpha channel + current_height, current_width = strip_frame_bgr.shape[:2] + strip_frame_bgra = np.zeros((final_output_height, final_output_width, 4), dtype=np.uint8) + + # Copy RGB data to BGR channels and set alpha to 255 for actual content + # Place content on the right side for progressive growth from right to left + strip_frame_bgra[:current_height, final_output_width-current_width:, :3] = strip_frame_bgr + strip_frame_bgra[:current_height, final_output_width-current_width:, 3] = 255 # Opaque for content + # Transparent areas remain alpha=0 + + # Add timestamp overlay if requested (after alpha setup) + if timestamp: + # Convert back to BGR for timestamp overlay, then back to BGRA + bgr_for_timestamp = strip_frame_bgra[:, :, :3].copy() + bgr_with_timestamp = add_timestamp_overlay(bgr_for_timestamp, frame_idx + 1, len(significant_columns)) + strip_frame_bgra[:, :, :3] = bgr_with_timestamp + + # Save PNG frame with zero-padded frame number + frame_filename = f"frame_{frame_idx:06d}.png" + frame_path = output_dir / frame_filename + cv2.imwrite(str(frame_path), strip_frame_bgra) + + if (frame_idx + 1) % 100 == 0: + print(f"Generated {frame_idx + 1}/{len(significant_columns)} PNG frames") + + print(f"PNG sequence saved to: {output_dir}") + print(f"Sequence contains {len(significant_columns)} frames at {fps} FPS reference") + print(f"Total duration: {len(significant_columns)/fps:.2f} seconds") + print(f"Import into video editor as PNG sequence at {fps} FPS") + + +def extract_row_strip_alpha(video_path, y_row, output_path, change_threshold=0.01, relax=0, start_frame=0, end_frame=None, fps=30, timestamp=False): + """ + Extract horizontal strip at y_row from each frame and create PNG sequence with alpha transparency. + Each frame shows the accumulated scan lines up to that point with transparent background. + + Args: + video_path: Path to input video file + y_row: Y-coordinate of the row to extract + output_path: Path for output directory (PNG sequence) + change_threshold: Minimum change threshold (0-1) to include frame + relax: Number of extra frames to include before/after threshold frames + start_frame: First frame to process (0-based) + end_frame: Last frame to process (None = until end) + fps: Output video frame rate (for reference) + timestamp: If True, embed frame count on bottom left corner + """ + cap = cv2.VideoCapture(str(video_path)) + + if not cap.isOpened(): + raise ValueError(f"Could not open video file: {video_path}") + + # Get video properties + total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) + frame_width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) + frame_height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + + if y_row >= frame_height: + raise ValueError(f"Row {y_row} is outside video height ({frame_height})") + + # Set end frame if not specified + if end_frame is None: + end_frame = total_frames - 1 + + print(f"Processing frames {start_frame} to {end_frame} ({end_frame - start_frame + 1} frames)...") + print(f"Extracting row {y_row} from {frame_width}x{frame_height} frames") + print(f"Change threshold: {change_threshold}") + if relax > 0: + print(f"Relax: including {relax} frames before/after threshold frames") + + # First pass: collect all rows and identify significant frames + all_rows = [] + changes = [] + frame_numbers = [] + previous_row = None + + frame_idx = 0 + while True: + ret, frame = cap.read() + if not ret: + break + + # Skip frames before start + if frame_idx < start_frame: + frame_idx += 1 + continue + + # Stop after end frame + if frame_idx > end_frame: + break + + # Extract current row + current_row = frame[y_row, :, :].copy() + all_rows.append(current_row) + frame_numbers.append(frame_idx) + + # Calculate change from previous frame + if previous_row is not None: + change = calculate_line_difference(current_row, previous_row) + changes.append(change) + else: + changes.append(0) # First frame has no change + + previous_row = current_row + frame_idx += 1 + + if (frame_idx - start_frame) % 100 == 0: + print(f"Processed {frame_idx - start_frame}/{end_frame - start_frame + 1} frames") + + cap.release() + + # Second pass: determine which frames to include + include_mask = [False] * len(all_rows) + + for i, change in enumerate(changes): + if i == 0 or change >= change_threshold: + # Mark this frame and surrounding frames + start = max(0, i - relax) + end = min(len(all_rows), i + relax + 1) + for j in range(start, end): + include_mask[j] = True + + # Collect significant rows + significant_rows = [] + significant_frame_numbers = [] + for i, row in enumerate(all_rows): + if include_mask[i]: + significant_rows.append(row) + significant_frame_numbers.append(frame_numbers[i]) + + included_frames = sum(include_mask) + skipped_frames = len(all_rows) - included_frames + + if not significant_rows: + raise ValueError("No significant changes detected. Try lowering the threshold.") + + print(f"Original frames in segment: {len(all_rows)}") + print(f"Included frames: {included_frames}") + print(f"Skipped frames: {skipped_frames}") + print(f"Compression ratio: {skipped_frames/len(all_rows):.1%}") + + # Create output directory + final_output_width = len(significant_rows) # After rotation + final_output_height = frame_width # After rotation + + output_dir = Path(output_path) + output_dir.mkdir(parents=True, exist_ok=True) + + print(f"Output PNG sequence dimensions (after rotation): {final_output_width}x{final_output_height}") + print(f"Creating PNG sequence at {fps} FPS reference: {output_dir}") + + # Generate PNG frames - each frame shows accumulated scan lines up to that point + for frame_idx in range(len(significant_rows)): + # Create accumulated strip image up to current frame + accumulated_rows = significant_rows[:frame_idx + 1] + + # Convert to numpy array and create the frame + strip_frame_bgr = np.stack(accumulated_rows, axis=0) + + # Rotate counter-clockwise 90 degrees to match image mode orientation + strip_frame_bgr = cv2.rotate(strip_frame_bgr, cv2.ROTATE_90_COUNTERCLOCKWISE) + + # Flip horizontally so time flows from right to left (strip photography convention) + strip_frame_bgr = cv2.flip(strip_frame_bgr, 1) + + # Create BGRA frame with alpha channel + current_height, current_width = strip_frame_bgr.shape[:2] + strip_frame_bgra = np.zeros((final_output_height, final_output_width, 4), dtype=np.uint8) + + # Copy RGB data to BGR channels and set alpha to 255 for actual content + # Place content on the right side for progressive growth from right to left + strip_frame_bgra[:current_height, final_output_width-current_width:, :3] = strip_frame_bgr + strip_frame_bgra[:current_height, final_output_width-current_width:, 3] = 255 # Opaque for content + # Transparent areas remain alpha=0 + + # Add timestamp overlay if requested (after alpha setup) + if timestamp: + # Convert back to BGR for timestamp overlay, then back to BGRA + bgr_for_timestamp = strip_frame_bgra[:, :, :3].copy() + bgr_with_timestamp = add_timestamp_overlay(bgr_for_timestamp, frame_idx + 1, len(significant_rows)) + strip_frame_bgra[:, :, :3] = bgr_with_timestamp + + # Save PNG frame with zero-padded frame number + frame_filename = f"frame_{frame_idx:06d}.png" + frame_path = output_dir / frame_filename + cv2.imwrite(str(frame_path), strip_frame_bgra) + + if (frame_idx + 1) % 100 == 0: + print(f"Generated {frame_idx + 1}/{len(significant_rows)} PNG frames") + + print(f"PNG sequence saved to: {output_dir}") + print(f"Sequence contains {len(significant_rows)} frames at {fps} FPS reference") + print(f"Total duration: {len(significant_rows)/fps:.2f} seconds") + print(f"Import into video editor as PNG sequence at {fps} FPS") + + def main(): """Main entry point for the strip photography tool.""" parser = argparse.ArgumentParser( @@ -932,6 +1272,12 @@ def main(): help="Embed frame count on bottom left corner (video mode only)" ) + parser.add_argument( + "--alpha", + action="store_true", + help="Generate PNG sequence with alpha transparency for video editing (video mode only)" + ) + args = parser.parse_args() # Validate input file @@ -982,6 +1328,10 @@ def main(): print("Error: Cannot use --video and --debug modes together") sys.exit(1) + if args.alpha and not args.video: + print("Error: --alpha can only be used with --video mode") + sys.exit(1) + # Validate FPS if args.fps <= 0: print("Error: --fps must be positive") @@ -992,7 +1342,11 @@ def main(): output_path = Path(args.output) # Add appropriate extension if no extension provided if not output_path.suffix: - if args.video: + if args.video and args.alpha: + # For alpha mode, we'll create a directory for PNG sequence + output_path = output_path.with_suffix('') # Remove any extension + print(f"No extension specified for alpha video mode, using directory: {output_path}") + elif args.video: output_path = output_path.with_suffix('.avi') print(f"No extension specified for video mode, using: {output_path}") else: @@ -1012,7 +1366,10 @@ def main(): # Include threshold in filename threshold_str = f"t{args.threshold}".replace(".", "_") - if args.video: + if args.video and args.alpha: + fps_str = f"fps{args.fps}".replace(".", "_") + output_filename = f"{video_path.stem}_{uuid_prefix}_{threshold_str}_{fps_str}_alpha" + elif args.video: fps_str = f"fps{args.fps}".replace(".", "_") output_filename = f"{video_path.stem}_{uuid_prefix}_{threshold_str}_{fps_str}.avi" else: @@ -1036,6 +1393,20 @@ def main(): start_frame=args.start, end_frame=args.end) print("Change analysis completed successfully!") + elif args.video and args.alpha: + # Alpha video mode: create PNG sequence with alpha transparency + print("Alpha video mode: Creating PNG sequence with alpha transparency") + + if args.xcolumn is not None: + print(f"Column mode: Extracting vertical line at x={args.xcolumn}") + extract_column_strip_alpha(video_path, args.xcolumn, output_path, args.threshold, args.relax, + args.start, args.end, args.fps, args.timestamp) + else: + print(f"Row mode: Extracting horizontal line at y={args.yrow}") + extract_row_strip_alpha(video_path, args.yrow, output_path, args.threshold, args.relax, + args.start, args.end, args.fps, args.timestamp) + + print("Alpha PNG sequence generation completed successfully!") elif args.video: # Video mode: create MJPEG video with accumulated scan lines print("Video mode: Creating MJPEG video with accumulated scan lines") diff --git a/readme.md b/readme.md index 67f39fa..3ebc56a 100644 --- a/readme.md +++ b/readme.md @@ -41,6 +41,12 @@ Output: `results/video/line500fps32pix_a3f2_t0_01_fps30_0.avi` uv run main.py .\line500fps32pix.mp4 --video --fps 30 --timestamp ``` +**Alpha Video Mode** - Generate PNG sequence with transparency for video editing: +```bash +uv run main.py .\line500fps32pix.mp4 --video --alpha --fps 30 --timestamp +``` +Output: `results/video/line500fps32pix_a3f2_t0_01_fps30_0_alpha/` (directory with PNG sequence) + **Debug Mode** - Analyze changes and generate threshold recommendations: ```bash uv run main.py .\line500fps32pix.mp4 --debug @@ -73,6 +79,7 @@ uv sync - `--debug` - Analyze changes without creating strip image, outputs to `results/debug/` - `--video` - Generate MJPEG video showing accumulated scan lines over time - `--fps N` - Output video frame rate (default: 30.0, only used with `--video`) +- `--alpha` - Generate PNG sequence with alpha transparency for video editing (video mode only) - `--timestamp` / `--ts` - Embed frame count on bottom left corner (video mode only) - `--timeline` - Overlay frame numbers as timeline/ruler on output image (image mode only) - `--start N` - Start frame number (0-based, default: 0) @@ -86,6 +93,11 @@ uv sync - Each frame shows accumulated scan lines up to that point in time - Final frame shows complete strip photography image - Video dimensions automatically determined by input video and number of significant frames +- **Alpha video mode** (`--video --alpha`): Creates PNG sequence with alpha transparency + - Perfect for video editing with transparent backgrounds instead of black padding + - Each PNG frame shows progressive scan line accumulation with alpha channel + - Import as PNG sequence in video editors at specified FPS + - No keyframe compression - ideal for editing workflows ## Features @@ -122,3 +134,12 @@ uv sync - Video dimensions automatically calculated based on input video and scan line count - Compatible with both row and column extraction modes - Timeline overlay not supported in video mode (use image mode with `--timeline` instead) + +**Alpha Video Mode Features**: +- Creates PNG sequence with BGRA (alpha channel) for video editing +- Transparent background instead of black padding - perfect for compositing +- No keyframe compression - each PNG is independent for smooth editing +- Progressive scan line accumulation with alpha transparency +- Import into video editors as PNG sequence at specified FPS +- Ideal for professional video editing workflows requiring transparency +- Compatible with all major video editors (Premiere, Final Cut, DaVinci Resolve, etc.)