Files
dataset-yolo-script/convert_yolo_to_labelme.py
T
2026-02-04 15:22:28 +07:00

591 lines
22 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Script to convert YOLO txt label format to LabelMe JSON format.
YOLO format: class_id x_center y_center width height (normalized 0.0-1.0)
LabelMe format: JSON with shapes containing rectangles with pixel coordinates
"""
import os
import sys
import argparse
import json
import shutil
from pathlib import Path
try:
from PIL import Image
HAS_PIL = True
except ImportError:
HAS_PIL = False
print("Warning: PIL/Pillow not installed. Image dimension detection required for conversion.")
print("Install with: pip install pillow")
def get_image_dimensions(image_path):
"""Get image width and height."""
if not HAS_PIL:
return None, None
try:
with Image.open(image_path) as img:
return img.size # Returns (width, height)
except Exception as e:
print(f"Warning: Could not read image {image_path}: {e}")
return None, None
def yolo_to_labelme_rectangle(x_center_norm, y_center_norm, width_norm, height_norm,
img_width, img_height):
"""
Convert YOLO normalized bounding box to LabelMe rectangle coordinates.
Args:
x_center_norm, y_center_norm, width_norm, height_norm: Normalized coordinates (0.0-1.0)
img_width, img_height: Image dimensions in pixels
Returns:
List of two points: [[x1, y1], [x2, y2]] for top-left and bottom-right corners
"""
# Denormalize center coordinates and dimensions
x_center = x_center_norm * img_width
y_center = y_center_norm * img_height
width = width_norm * img_width
height = height_norm * img_height
# Calculate top-left and bottom-right corners
x1 = x_center - width / 2.0
y1 = y_center - height / 2.0
x2 = x_center + width / 2.0
y2 = y_center + height / 2.0
# Ensure coordinates are within image bounds
x1 = max(0.0, min(img_width, x1))
y1 = max(0.0, min(img_height, y1))
x2 = max(0.0, min(img_width, x2))
y2 = max(0.0, min(img_height, y2))
return [[float(x1), float(y1)], [float(x2), float(y2)]]
def is_normalized(value):
"""Check if a coordinate value is normalized (0.0-1.0)."""
return 0.0 <= float(value) <= 1.0
def find_image_file(txt_file, image_extensions=None):
"""
Find corresponding image file for a txt annotation file.
Args:
txt_file: Path to txt annotation file
image_extensions: List of image extensions to try (default: ['.jpg', '.jpeg', '.png', '.bmp'])
Returns:
Path to image file or None if not found
"""
if image_extensions is None:
image_extensions = ['.jpg', '.jpeg', '.png', '.bmp', '.tif', '.tiff']
txt_file = Path(txt_file)
base_name = txt_file.stem
txt_dir = txt_file.parent
# First, check if txt_file is in a 'labels' directory
# If so, look for corresponding 'images' directory
if txt_dir.name.lower() == 'labels':
# Try to find images directory at the same level
images_dir = txt_dir.parent / 'images'
if images_dir.exists():
# Look for image in images directory
for ext in image_extensions:
potential_image = images_dir / f"{base_name}{ext}"
if potential_image.exists():
return potential_image
# Try case variations
for ext in image_extensions:
for case_ext in [ext, ext.upper(), ext.capitalize()]:
potential_image = images_dir / f"{base_name}{case_ext}"
if potential_image.exists():
return potential_image
# Check in same directory as txt file
for ext in image_extensions:
potential_image = txt_dir / f"{base_name}{ext}"
if potential_image.exists():
return potential_image
# Check with case variations in same directory
for ext in image_extensions:
for case_ext in [ext, ext.upper(), ext.capitalize()]:
potential_image = txt_dir / f"{base_name}{case_ext}"
if potential_image.exists():
return potential_image
return None
def find_images_directory_for_labels(labels_dir):
"""
Find the corresponding images directory for a labels directory.
Args:
labels_dir: Path to labels directory
Returns:
Path to images directory or None if not found
"""
labels_dir = Path(labels_dir)
# If the directory name is 'labels', look for 'images' at the same level
if labels_dir.name.lower() == 'labels':
images_dir = labels_dir.parent / 'images'
if images_dir.exists():
return images_dir
return None
def convert_yolo_to_labelme(txt_file, image_file=None, class_names=None,
image_extensions=None, include_image_data=False):
"""
Convert a single YOLO txt annotation file to LabelMe JSON format.
Args:
txt_file: Path to YOLO txt annotation file
image_file: Path to corresponding image file (optional, will be searched if not provided)
class_names: Dictionary mapping class_id to class name (optional)
image_extensions: List of image extensions to search (default: ['.jpg', '.jpeg', '.png', '.bmp'])
include_image_data: Whether to include base64-encoded image data in JSON
Returns:
Dictionary with LabelMe JSON structure
"""
txt_file = Path(txt_file)
if not txt_file.exists():
raise FileNotFoundError(f"Annotation file not found: {txt_file}")
# Find image file if not provided
if image_file is None:
image_file = find_image_file(txt_file, image_extensions)
if image_file is None:
raise FileNotFoundError(
f"Image file not found for {txt_file}. "
f"Please provide image_file or ensure image exists in same directory."
)
image_file = Path(image_file)
if not image_file.exists():
raise FileNotFoundError(f"Image file not found: {image_file}")
# Get image dimensions
img_width, img_height = get_image_dimensions(image_file)
if img_width is None or img_height is None:
raise ValueError(
f"Could not determine image dimensions for {image_file}. "
f"PIL/Pillow is required for this operation."
)
# Read YOLO annotations
shapes = []
with open(txt_file, 'r') as f:
for line_num, line in enumerate(f, 1):
line = line.strip()
if not line: # Skip empty lines
continue
parts = line.split()
if len(parts) < 5:
print(f"Warning: Invalid YOLO format in {txt_file} line {line_num}: {line}")
continue
try:
class_id = int(parts[0])
x_center = float(parts[1])
y_center = float(parts[2])
width = float(parts[3])
height = float(parts[4])
# Check if coordinates are normalized
if not (is_normalized(x_center) and is_normalized(y_center) and
is_normalized(width) and is_normalized(height)):
print(f"Warning: Coordinates in {txt_file} line {line_num} may not be normalized. "
f"Assuming normalized format.")
# Convert to LabelMe rectangle format
points = yolo_to_labelme_rectangle(
x_center, y_center, width, height, img_width, img_height
)
# Get class name
if class_names and class_id in class_names:
label = class_names[class_id]
else:
label = str(class_id) # Use class_id as label if no mapping provided
# Create shape annotation
shape = {
"label": label,
"points": points,
"group_id": None,
"shape_type": "rectangle",
"flags": {}
}
shapes.append(shape)
except (ValueError, IndexError) as e:
print(f"Warning: Could not parse line {line_num} in {txt_file}: {line} - {e}")
continue
# Get image data if requested
image_data = None
if include_image_data:
try:
with open(image_file, 'rb') as f:
import base64
image_data = base64.b64encode(f.read()).decode('utf-8')
except Exception as e:
print(f"Warning: Could not encode image data: {e}")
# Create LabelMe JSON structure
labelme_json = {
"version": "5.0.1",
"flags": {},
"shapes": shapes,
"imagePath": image_file.name,
"imageData": image_data,
"imageHeight": img_height,
"imageWidth": img_width
}
return labelme_json
def convert_dataset(input_dir, output_dir=None, class_names_file=None,
image_extensions=None, include_image_data=False,
copy_images=False, recursive=False):
"""
Convert a directory of YOLO txt annotations to LabelMe JSON format.
Args:
input_dir: Input directory containing txt files and images
output_dir: Output directory for LabelMe JSON files (optional, if None, JSON files are placed next to images)
class_names_file: Path to file with class names (one per line, optional)
image_extensions: List of image extensions to search
include_image_data: Whether to include base64-encoded image data
copy_images: Whether to copy images to output directory (only used if output_dir is specified)
recursive: Whether to process subdirectories recursively
Returns:
Dictionary with conversion statistics
"""
input_dir = Path(input_dir)
if not input_dir.exists():
raise FileNotFoundError(f"Input directory not found: {input_dir}")
# Load class names if provided
class_names = None
if class_names_file:
class_names_file = Path(class_names_file)
if class_names_file.exists():
class_names = {}
with open(class_names_file, 'r') as f:
for idx, line in enumerate(f):
class_name = line.strip()
if class_name:
class_names[idx] = class_name
print(f"Loaded {len(class_names)} class names from {class_names_file}")
else:
print(f"Warning: Class names file not found: {class_names_file}")
# Find all txt files (recursive or not)
if recursive:
txt_files = list(input_dir.rglob('*.txt'))
else:
txt_files = list(input_dir.glob('*.txt'))
if not txt_files:
search_type = "recursively" if recursive else "in"
raise ValueError(f"No .txt files found {search_type} {input_dir}")
stats = {
'files_processed': 0,
'total_annotations': 0,
'errors': []
}
# Process each txt file
for txt_file in txt_files:
try:
# Find corresponding image
image_file = find_image_file(txt_file, image_extensions)
if not image_file:
error_msg = f"Image file not found for {txt_file}"
stats['errors'].append(error_msg)
print(f"ERROR: {error_msg}")
continue
# Convert to LabelMe format
labelme_json = convert_yolo_to_labelme(
txt_file, image_file, class_names, image_extensions, include_image_data
)
# Determine where to place the JSON file
if output_dir:
# If output_dir is specified, preserve relative path structure when recursive
output_dir = Path(output_dir)
if recursive:
# Preserve relative path from input_dir
relative_path = txt_file.relative_to(input_dir)
output_subdir = output_dir / relative_path.parent
output_subdir.mkdir(parents=True, exist_ok=True)
json_file = output_subdir / f"{image_file.stem}.json"
# Copy image if requested, preserving directory structure
if copy_images:
output_image = output_subdir / image_file.name
if not output_image.exists():
shutil.copy2(image_file, output_image)
else:
# Non-recursive: just use output_dir
output_dir.mkdir(parents=True, exist_ok=True)
json_file = output_dir / f"{image_file.stem}.json"
# Copy image if requested
if copy_images:
output_image = output_dir / image_file.name
if not output_image.exists():
shutil.copy2(image_file, output_image)
else:
# Check if txt_file is in a 'labels' directory
# If so, place JSON in corresponding 'images' directory
txt_dir = txt_file.parent
if txt_dir.name.lower() == 'labels':
images_dir = find_images_directory_for_labels(txt_dir)
if images_dir:
# Place JSON in images directory
json_file = images_dir / f"{image_file.stem}.json"
else:
# Fallback: place next to image file
json_file = image_file.parent / f"{image_file.stem}.json"
else:
# Otherwise, place JSON file next to the image file
json_file = image_file.parent / f"{image_file.stem}.json"
json_file.parent.mkdir(parents=True, exist_ok=True)
with open(json_file, 'w') as f:
json.dump(labelme_json, f, indent=2)
stats['files_processed'] += 1
stats['total_annotations'] += len(labelme_json['shapes'])
print(f"Processed: {txt_file} -> {json_file} ({len(labelme_json['shapes'])} annotations)")
except Exception as e:
error_msg = f"Error processing {txt_file}: {str(e)}"
stats['errors'].append(error_msg)
print(f"ERROR: {error_msg}")
return stats
def main():
parser = argparse.ArgumentParser(
description='Convert YOLO txt label format to LabelMe JSON format',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Convert single file (JSON placed in images folder if txt is in labels folder)
python convert_yolo_to_labelme.py train/labels/x.txt --image train/images/x.jpg
# Output: train/images/x.json
# Convert directory (JSON files placed in images folders when txt files are in labels folders)
python convert_yolo_to_labelme.py --input-dir ./train/labels
# Converts train/labels/x.txt -> train/images/x.json
# Convert directory recursively (processes all subdirectories)
python convert_yolo_to_labelme.py --input-dir ./dataset --recursive
# Converts train/labels/x.txt -> train/images/x.json
# Converts val/labels/y.txt -> val/images/y.json
# Convert directory with custom output directory
python convert_yolo_to_labelme.py --input-dir ./labels --output-dir ./labelme_annotations
# Convert recursively with custom output directory (preserves directory structure)
python convert_yolo_to_labelme.py --input-dir ./labels --output-dir ./labelme_annotations --recursive
# Convert with class names file
python convert_yolo_to_labelme.py --input-dir ./labels --class-names classes.txt
# Convert and include image data in JSON
python convert_yolo_to_labelme.py --input-dir ./labels --include-image-data
"""
)
parser.add_argument(
'input',
nargs='?',
help='Input YOLO txt file (if converting single file)'
)
parser.add_argument(
'--image',
type=str,
help='Image file path (required for single file conversion)'
)
parser.add_argument(
'--output', '-o',
type=str,
help='Output JSON file path (for single file conversion). If not specified, JSON is placed in images folder when txt is in labels folder, otherwise next to image file.'
)
parser.add_argument(
'--input-dir',
type=str,
help='Input directory containing txt files and images (for batch conversion)'
)
parser.add_argument(
'--output-dir',
type=str,
default=None,
help='Output directory for LabelMe JSON files (for batch conversion). If not specified, JSON files are placed in the images folder when txt files are in a labels folder (e.g., train/labels/x.txt -> train/images/x.json), otherwise next to image files.'
)
parser.add_argument(
'--class-names',
type=str,
dest='class_names_file',
help='File with class names (one per line, line number = class_id)'
)
parser.add_argument(
'--image-extensions',
nargs='+',
default=['.jpg', '.jpeg', '.png', '.bmp', '.tif', '.tiff'],
help='Image file extensions to search for (default: .jpg .jpeg .png .bmp .tif .tiff)'
)
parser.add_argument(
'--include-image-data',
action='store_true',
help='Include base64-encoded image data in JSON (increases file size)'
)
parser.add_argument(
'--copy-images',
action='store_true',
help='Copy images to output directory (for batch conversion)'
)
parser.add_argument(
'--recursive', '-r',
action='store_true',
help='Process subdirectories recursively'
)
args = parser.parse_args()
# Determine mode: single file or batch
if args.input:
# Single file mode
if not args.image:
parser.error("--image is required for single file conversion")
# Load class names if provided
class_names = None
if args.class_names_file:
class_names_file = Path(args.class_names_file)
if class_names_file.exists():
class_names = {}
with open(class_names_file, 'r') as f:
for idx, line in enumerate(f):
class_name = line.strip()
if class_name:
class_names[idx] = class_name
else:
print(f"Warning: Class names file not found: {class_names_file}")
try:
labelme_json = convert_yolo_to_labelme(
args.input,
args.image,
class_names,
args.image_extensions,
args.include_image_data
)
# Determine output file path
if args.output:
output_file = Path(args.output)
else:
# Check if txt file is in a 'labels' directory
# If so, place JSON in corresponding 'images' directory
txt_file = Path(args.input)
txt_dir = txt_file.parent
image_file = Path(args.image)
if txt_dir.name.lower() == 'labels':
images_dir = find_images_directory_for_labels(txt_dir)
if images_dir:
# Place JSON in images directory
output_file = images_dir / f"{image_file.stem}.json"
else:
# Fallback: place next to image file
output_file = image_file.parent / f"{image_file.stem}.json"
else:
# Place JSON file next to the image file
output_file = image_file.parent / f"{image_file.stem}.json"
output_file.parent.mkdir(parents=True, exist_ok=True)
with open(output_file, 'w') as f:
json.dump(labelme_json, f, indent=2)
print(f"Successfully converted {args.input} to {output_file}")
print(f" Annotations: {len(labelme_json['shapes'])}")
print(f" Image: {labelme_json['imagePath']} ({labelme_json['imageWidth']}x{labelme_json['imageHeight']})")
except Exception as e:
print(f"ERROR: {e}", file=sys.stderr)
sys.exit(1)
elif args.input_dir:
# Batch mode - output_dir is optional
try:
stats = convert_dataset(
args.input_dir,
args.output_dir,
args.class_names_file,
args.image_extensions,
args.include_image_data,
args.copy_images,
args.recursive
)
print("\n" + "="*50)
print("Conversion Summary:")
print(f" Files processed: {stats['files_processed']}")
print(f" Total annotations: {stats['total_annotations']}")
if stats['errors']:
print(f" Errors: {len(stats['errors'])}")
for error in stats['errors']:
print(f" - {error}")
print("="*50)
except Exception as e:
print(f"ERROR: {e}", file=sys.stderr)
sys.exit(1)
else:
parser.error("Either provide input file or --input-dir for batch conversion")
if __name__ == '__main__':
main()