coco json 分类标注工具源代码

全程键盘标注,方便快捷

python 复制代码
import glob
import sys
import json
import os
import shutil
from PyQt5.QtWidgets import (
    QApplication, QMainWindow, QTableWidget, QTableWidgetItem,
    QSplitter, QVBoxLayout, QWidget, QPushButton, QRadioButton,
    QButtonGroup, QLabel, QHBoxLayout, QMessageBox, QScrollArea
)
from PyQt5.QtCore import Qt, QUrl
from PyQt5.QtGui import QColor, QPixmap, QImage, QPainter, QPen, QKeyEvent
from natsort import natsorted

class CustomTableWidget(QTableWidget):
    def __init__(self, parent=None):
        super().__init__(parent)
        self.parent_window = parent

    def keyPressEvent(self, event):
        """重写表格的键盘事件处理"""
        if event.key() == Qt.Key_Up or event.key() == Qt.Key_Down:
            # 获取当前选中行
            current_row = self.currentRow()

            if event.key() == Qt.Key_Up and current_row > 0:
                # 上箭头键,选择上一行
                new_row = current_row - 1
                self.selectRow(new_row)
                self.setCurrentCell(new_row, 0)
                if self.parent_window:
                    self.parent_window.display_image(new_row, 0)
            elif event.key() == Qt.Key_Down and current_row < self.rowCount() - 1:
                # 下箭头键,选择下一行
                new_row = current_row + 1
                self.selectRow(new_row)
                self.setCurrentCell(new_row, 0)
                if self.parent_window:
                    self.parent_window.display_image(new_row, 0)
            # 数字键(主键盘和小键盘)监听
        elif Qt.Key_0 <= event.key() <= Qt.Key_9:
            num = event.key() - Qt.Key_0
            if self.parent_window:
                self.parent_window.handle_number_key(num)

        elif Qt.KeypadModifier & event.modifiers() and Qt.Key_0 <= event.key() <= Qt.Key_9:
            num = event.key() - Qt.Key_0
            print(f"小键盘数字键按下: {num}")
            if self.parent_window:
                self.parent_window.handle_number_key(num)
        else:
            # 其他按键按默认方式处理
            super().keyPressEvent(event)


class ImageAnnotator(QMainWindow):
    def __init__(self, base_dir):
        super().__init__()
        self.setWindowTitle(f"图片标注可视化工具 - {os.path.basename(base_dir)}")
        self.setGeometry(100, 100, 1400, 900)

        # 启用拖放功能
        self.setAcceptDrops(True)

        # 主布局
        main_widget = QWidget()
        self.setCentralWidget(main_widget)
        layout = QVBoxLayout()
        main_widget.setLayout(layout)

        # 添加当前目录显示
        self.dir_label = QLabel(f"当前目录: {base_dir}")
        self.dir_label.setStyleSheet("background-color: #e0e0e0; padding: 5px;")
        self.dir_label.setFixedHeight(40)  # 设置高度为40像素
        self.dir_label.setTextInteractionFlags(Qt.TextSelectableByMouse)  #启用鼠标选中文本复制
        layout.addWidget(self.dir_label)

        # 分割左右区域
        splitter = QSplitter(Qt.Horizontal)

        # 左侧:图片文件列表(表格)- 使用自定义表格控件
        self.table = CustomTableWidget(self)
        self.table.setColumnCount(3)
        self.table.setHorizontalHeaderLabels(["图片文件", "状态", "标注数量"])
        self.table.setEditTriggers(QTableWidget.NoEditTriggers)
        self.table.cellClicked.connect(self.display_image)
        self.table.setColumnWidth(0, 250)
        self.table.setColumnWidth(1, 100)
        self.table.setColumnWidth(2, 100)

        # 设置表格可以获取焦点,以便接收键盘事件
        self.table.setFocusPolicy(Qt.StrongFocus)
        self.table.setSelectionBehavior(QTableWidget.SelectRows)
        self.table.setSelectionMode(QTableWidget.SingleSelection)

        # 右侧:图片显示和标注区域
        right_panel = QWidget()
        right_layout = QVBoxLayout()

        # 图片显示区域
        self.image_label = QLabel()
        self.image_label.setAlignment(Qt.AlignCenter)
        self.image_label.setMinimumSize(500, 400)
        self.image_label.setStyleSheet("border: 1px solid gray; background-color: #f0f0f0;")
        self.image_label.setText("请选择图片")

        # 添加拖拽提示
        self.image_label.setAcceptDrops(True)

        # 创建滚动区域用于显示大图
        scroll_area = QScrollArea()
        scroll_area.setWidget(self.image_label)
        scroll_area.setWidgetResizable(True)

        self.pic_label=QLabel("图片预览(带标注框):")
        right_layout.addWidget(self.pic_label)
        # right_layout.addWidget(scroll_area)
        right_layout.addWidget(scroll_area, stretch=2)

        # 标注选项
        self.label_map = {0: "ok", 1: "err", 2: "other"}

        # 创建分类目录
        self.output_dirs = {}
        self.base_dir = base_dir
        self.create_output_dirs()

        # 单选按钮组
        self.radio_group = QButtonGroup()

        right_layout.addWidget(QLabel("分类选项:"))

        # 创建单选按钮的垂直布局
        radio_layout = QVBoxLayout()
        radio_widget = QWidget()
        radio_widget.setLayout(radio_layout)

        for key, text in self.label_map.items():
            radio = QRadioButton(f"{text} ({key})")
            radio_layout.addWidget(radio)
            self.radio_group.addButton(radio, key)

        self.radio_group.buttonClicked[int].connect(self.on_radio_selected)
        right_layout.addWidget(radio_widget)

        # 显示目标目录信息
        dir_info = QLabel("分类后图片和JSON将自动移动到对应目录:\n" +
                          "\n".join([f"{label_id}_{label_name}" for label_id, label_name in self.label_map.items()]))
        dir_info.setStyleSheet("color: blue; font-size: 10px;")
        right_layout.addWidget(dir_info)

        # 添加拖拽提示
        drag_info = QLabel("提示: 拖拽目录到此窗口可切换数据源")
        drag_info.setStyleSheet("color: green; font-size: 10px; background-color: #f0f0f0; padding: 5px;")
        right_layout.addWidget(drag_info)

        # right_layout.addStretch()
        right_layout.addStretch(1)

        right_panel.setLayout(right_layout)

        # 添加到布局
        splitter.addWidget(self.table)
        splitter.addWidget(right_panel)
        splitter.setSizes([400, 800])#两列的宽度比例

        layout.addWidget(splitter)

        # 存储所有图片的原始信息
        self.all_images = []  # 存储 (原始图片路径, 原始JSON路径, 新图片路径, 新JSON路径, 状态, 标注数量) 的元组

        # 初始化图片文件列表
        self.refresh_image_files()
        self.load_image_files()

        # 默认选中第一行
        if self.all_images:
            self.table.selectRow(0)
            self.table.setFocus()
            self.display_image(0, 0)

    def create_output_dirs(self):
        """创建分类目录"""
        self.output_dirs = {}
        for label_id, label_name in self.label_map.items():
            dir_path = os.path.join(self.base_dir, f"{label_id}_{label_name}")
            # os.makedirs(dir_path, exist_ok=True)
            self.output_dirs[label_id] = dir_path

    def dragEnterEvent(self, event):
        """处理拖拽进入事件"""
        if event.mimeData().hasUrls():
            event.acceptProposedAction()

    def dropEvent(self, event):
        """处理拖拽释放事件"""
        urls = event.mimeData().urls()
        if urls:
            # 获取拖拽的第一个路径
            path = urls[0].toLocalFile()
            if os.path.isfile(path):
                self.base_dir=os.path.dirname(path)
            elif os.path.isdir(path):
                # 更新基础目录
                self.base_dir = path
            else:
                QMessageBox.warning(self, "错误", "请拖拽目录而非文件!")
                return
            # 更新窗口标题和目录标签
            self.setWindowTitle(f"图片标注可视化工具 - {os.path.basename(self.base_dir)}")
            self.dir_label.setText(f"当前目录: {self.base_dir}")

            # 重新创建分类目录
            self.create_output_dirs()

            # 刷新图片列表
            self.refresh_image_files()
            self.load_image_files()

            # 默认选中第一行
            if self.all_images:
                self.table.selectRow(0)
                self.table.setFocus()
                self.display_image(0, 0)

            # 显示成功消息
            self.statusBar().showMessage(f"已切换到目录: {self.base_dir}", 3000)

    def refresh_image_files(self):
        """刷新图片文件列表,包含所有图片(包括已分类的)"""
        self.all_images = []

        img_files = ['%s/%s' % (i[0].replace("\\", "/"), j) for i in os.walk(self.base_dir) for j in i[-1] if
                     j.lower().endswith(('png', 'jpg', 'jpeg'))]

        img_files = natsorted(img_files)

        for img_path in img_files:

            dir_name =os.path.basename(os.path.dirname(img_path))

            json_path = self.find_json_file(img_path)
            annotation_count = self.get_annotation_count(json_path)
            if any(f"{label_id}_{self.label_map[label_id]}" in dir_name for label_id in self.label_map):
                self.all_images.append \
                    ((img_path, json_path, img_path, json_path, f"已分类: {dir_name}", annotation_count))
            else:
                self.all_images.append((img_path, json_path, None, None, "待分类", annotation_count))

    def find_json_file(self, img_path):
        """查找与图片对应的JSON文件"""
        # 尝试多种可能的JSON文件名
        base_name = os.path.splitext(img_path)[0]
        possible_json_paths = [
            base_name + '.json',
            base_name + '_hand.json',
            base_name.replace('_s', '') + '.json',  # 处理_s后缀的图片
        ]

        for json_path in possible_json_paths:
            if os.path.exists(json_path):
                return json_path
        return None

    def get_annotation_count(self, json_path):
        """从JSON文件中获取标注数量"""
        if not json_path or not os.path.exists(json_path):
            return 0

        try:
            with open(json_path, 'r', encoding='utf-8') as f:
                data = json.load(f)
                return len(data.get('shapes', []))
        except:
            return 0

    def move_image_and_json(self, img_path, json_path, label_id):
        """将图片和JSON文件移动到对应的分类目录"""
        try:
            if label_id not in self.output_dirs:
                return None, None

            target_dir = self.output_dirs[label_id]
            os.makedirs(target_dir,exist_ok=True)
            img_filename = os.path.basename(img_path)

            # 移动图片文件
            img_target_path = os.path.join(target_dir, img_filename)
            counter = 1
            base_name, ext = os.path.splitext(img_filename)
            if os.path.exists(img_target_path):
                json_target_path = None
                if json_path and os.path.exists(json_path):
                    json_filename = os.path.basename(json_path)
                    json_target_path = os.path.join(target_dir, json_filename)

                return img_target_path, json_target_path

            shutil.move(img_path, img_target_path)

            # 移动JSON文件(如果存在)
            json_target_path = None
            if json_path and os.path.exists(json_path):
                json_filename = os.path.basename(json_path)
                json_target_path = os.path.join(target_dir, json_filename)
                shutil.move(json_path, json_target_path)

            return img_target_path, json_target_path

        except Exception as e:
            print(f"移动文件失败: {e}")
            return None, None

    def on_radio_selected(self, checked_id):
        """单选按钮选择事件"""
        current_row = self.table.currentRow()
        if current_row >= 0 and current_row < len(self.all_images):
            original_img, original_json, current_img, current_json, status, annotation_count = self.all_images[
                current_row]
            # checked_id = self.radio_group.checkedId()
            if checked_id <0:
                print('checked_id <0',checked_id)
                return
            # 使用当前路径(如果已移动)或原始路径
            img_path = current_img if current_img else original_img
            json_path = current_json if current_json else original_json

            # 检查图片文件是否存在
            if not os.path.exists(img_path):
                QMessageBox.warning(self, "警告", "图片文件不存在!")
                self.refresh_image_files()
                self.load_image_files()
                return

            # 移动图片和JSON到对应目录
            new_img_path, new_json_path = self.move_image_and_json(img_path, json_path, checked_id)
            if new_img_path:
                # 更新内存中的数据
                label_name = self.label_map.get(checked_id, "未知")
                label_show=f"{checked_id}_{label_name}"
                self.all_images[current_row] = \
                    (
                        original_img, original_json, new_img_path, new_json_path, f"已分类: {label_show}", annotation_count)

                # 更新表格显示
                self.update_table_row(current_row)

                # 显示成功消息
                self.statusBar().showMessage(f"已分类: {os.path.basename(original_img)} -> {label_show}", 3000)

                # 自动选择下一行(如果还有未分类的图片)
                next_row = current_row+1
                next_row = min(len(self.all_images)-1,max(0,next_row))
                self.table.selectRow(next_row)
                self.table.setCurrentCell(next_row, 0)
                self.table.setFocus()
                self.display_image(next_row, 0)
            else:
                QMessageBox.warning(self, "错误", "移动文件失败!")

    def find_next_unclassified(self, start_row):
        """从指定行开始查找下一个未分类的图片"""
        for i in range(start_row + 1, len(self.all_images)):
            original_img, original_json, current_img, current_json, status, annotation_count = self.all_images[i]
            if status == "待分类":
                return i
        # 如果后面没有未分类的,从开头找
        for i in range(0, start_row):
            original_img, original_json, current_img, current_json, status, annotation_count = self.all_images[i]
            if status == "待分类":
                return i
        return -1  # 没有未分类的图片了

    def update_table_row(self, row):
        """更新表格中指定行的显示状态"""
        if 0 <= row < len(self.all_images):
            original_img, original_json, current_img, current_json, status, annotation_count = self.all_images[row]

            # 文件名列
            file_item = QTableWidgetItem(os.path.basename(original_img))

            # 状态列
            status_item = QTableWidgetItem(status)

            # 标注数量列
            count_item = QTableWidgetItem(str(annotation_count))

            # 设置背景色
            if status == "待分类":
                color = QColor(255, 200, 200)  # 浅红色

            elif "err" in status:
                # color = QColor(255, 165, 0)  # 浅橙色
                color = QColor(255, 102, 102)  # 浅橙色
            else:
                color = QColor(200, 255, 200)  # 浅绿色

            file_item.setBackground(color)
            status_item.setBackground(color)
            count_item.setBackground(color)

            self.table.setItem(row, 0, file_item)
            self.table.setItem(row, 1, status_item)
            self.table.setItem(row, 2, count_item)

    def load_image_files(self):
        """加载图片文件到表格"""
        self.table.setRowCount(len(self.all_images))

        for i, (original_img, original_json, current_img, current_json, status, annotation_count) in enumerate(
                self.all_images):
            file_item = QTableWidgetItem(os.path.basename(original_img))
            status_item = QTableWidgetItem(status)
            count_item = QTableWidgetItem(str(annotation_count))

            # 设置背景色
            if status == "待分类":
                color = QColor(255, 200, 200)  # 浅红色
            elif "err" in status:
                color = QColor(255, 102, 102)  # 浅红色
            else:
                color = QColor(200, 255, 200)  # 浅绿色

            file_item.setBackground(color)
            status_item.setBackground(color)
            count_item.setBackground(color)

            self.table.setItem(i, 0, file_item)
            self.table.setItem(i, 1, status_item)
            self.table.setItem(i, 2, count_item)

        # 显示统计信息
        unclassified_count = sum(1 for _, _, _, _, status, _ in self.all_images if status == "待分类")
        total_annotations = sum(annotation_count for _, _, _, _, _, annotation_count in self.all_images)
        self.statusBar().showMessage(
            f"共 {len(self.all_images)} 张图片,{unclassified_count} 张待分类,总标注数: {total_annotations}")

    def handle_number_key(self,key_num):
        if key_num<len(self.label_map):
            self.on_radio_selected(key_num)


    def display_image(self, row, column):
        """显示选中的图片及其标注,并更新单选按钮状态"""
        if row < 0 or row >= len(self.all_images):
            self.image_label.setText("无图片可显示")
            self.image_label.setPixmap(QPixmap())
            return

        original_img, original_json, current_img, current_json, status, annotation_count = self.all_images[row]

        # 使用当前路径(如果已移动)或原始路径
        img_path = current_img if current_img else original_img
        json_path = current_json if current_json else original_json

        # 检查文件是否存在
        if not os.path.exists(img_path):
            QMessageBox.warning(self, "警告", "图片文件不存在!")
            self.refresh_image_files()
            self.load_image_files()
            return

        try:
            # 加载图片
            pixmap = QPixmap(img_path)

            if pixmap.isNull():
                self.image_label.setText("无法加载图片")
                return

            # 如果有JSON标注文件,绘制标注框
            if json_path and os.path.exists(json_path):
                try:
                    with open(json_path, 'r', encoding='utf-8') as f:
                        annotation_data = json.load(f)

                    # 创建带标注的图片
                    annotated_pixmap = self.draw_annotations(pixmap, annotation_data)
                    pixmap = annotated_pixmap
                except Exception as e:
                    print(f"加载标注文件失败: {e}")

            # 缩放图片以适应显示区域
            scaled_pixmap = pixmap.scaled(
                self.image_label.width() - 20,
                self.image_label.height() - 20,
                Qt.KeepAspectRatio,
                Qt.SmoothTransformation
            )

            self.image_label.setPixmap(scaled_pixmap)

            # 根据状态设置单选按钮
            self.update_radio_buttons(status)

        except Exception as e:
            self.image_label.setText(f"加载图片出错: {str(e)}")

    def update_radio_buttons(self, status_str):
        """根据状态更新单选按钮的选中状态"""
        # 清除所有选择
        self.radio_group.setExclusive(False)
        for btn in self.radio_group.buttons():
            btn.setChecked(False)
        self.radio_group.setExclusive(True)

        self.pic_label.setText(f"图片预览:{status_str}")
        # 如果状态是已分类,设置对应的单选按钮
        if status_str.startswith("已分类: "):
            label_name = status_str.replace("已分类: ", "")
            # 找到对应的标签ID
            for label_id, name in self.label_map.items():
                if name == label_name:

                    button = self.radio_group.button(label_id)
                    if button:
                        button.setChecked(True)
                    break

    def draw_annotations(self, pixmap, annotation_data):
        """在图片上绘制标注框"""
        # 创建可绘制的pixmap
        result_pixmap = QPixmap(pixmap.size())
        result_pixmap.fill(Qt.transparent)

        painter = QPainter(result_pixmap)
        painter.drawPixmap(0, 0, pixmap)

        # 设置画笔
        pen = QPen(Qt.red)
        pen.setWidth(3)
        painter.setPen(pen)

        # 绘制每个标注框
        for shape in annotation_data.get('shapes', []):
            if shape.get('shape_type') == 'rectangle' and len(shape['points']) == 2:
                points = shape['points']
                x1, y1 = points[0]
                x2, y2 = points[1]

                # 绘制矩形框
                painter.drawRect(int(x1), int(y1), int(x2 - x1), int(y2 - y1))

                # 绘制标签
                label = shape.get('label', '')
                painter.drawText(int(x1), int(y1) - 5, label)

        painter.end()
        return result_pixmap

if __name__ == "__main__":
    base_dir = r"D:\data\course_1027\chan_1028\dan\20251028_1643_part001_seg"  # 替换为你的图片目录路径

    app = QApplication(sys.argv)
    window = ImageAnnotator(base_dir)
    window.show()
    sys.exit(app.exec_())
相关推荐
勇敢牛牛_7 小时前
Rust真的适合写业务后端吗?
开发语言·后端·rust
要加油GW8 小时前
python使用vscode 需要配置全局的环境变量。
开发语言·vscode·python
B站计算机毕业设计之家8 小时前
python图像识别系统 AI多功能图像识别检测系统(11种识别功能)银行卡、植物、动物、通用票据、营业执照、身份证、车牌号、驾驶证、行驶证、车型、Logo✅
大数据·开发语言·人工智能·python·图像识别·1024程序员节·识别
快乐的钢镚子8 小时前
思腾合力云服务器远程连接
运维·服务器·python
苏打水com8 小时前
爬虫进阶实战:突破动态反爬,高效采集CSDN博客详情页数据
爬虫·python
ceclar1238 小时前
C++日期与时间
开发语言·c++
懒羊羊不懒@8 小时前
JavaSe—泛型
java·开发语言·人工智能·windows·设计模式·1024程序员节
Zhangzy@8 小时前
Rust Workspace 构建多项目体系
开发语言·前端·rust
麦麦鸡腿堡8 小时前
Java的三代日期类(Date,Calendar,LocalDateTime)
java·开发语言