Skip to content
大纲

方案构想

技术栈——newbing的建议

image-20230727024956952

image-20230727025936318

image-20230727030010136

命名实体识别主要功能

高亮显示效果与文本区域

image-20230727040756927

上述是jupter的案例

image-20230727040923476

上述是doccano的案例

image-20230727041212307

长文本手册 NER UI 的屏幕截图

上述是spacy的样例

image-20230727041421414

上述是yelo-annotation样例

image-20230808112411538

上述是prodigy的案例,可以看出其文本标注是通过分词构造span标签,再使用maker进行标注效果呈现,但其命名实体识别和关系标注是分开的两种不同的效果。prodigy演示链接

总体方案

初步构想

使用 Vue3 和 Vite 来创建前端的命名实体识别文本标注功能的步骤和方案:

  1. 项目初始化: 使用 Vite 初始化一个 Vue3 项目:

    bash
    npm init @vitejs/app my-app --template vue
  2. 设计数据结构: 设计一个数据结构来保存标注的实体。例如,可以使用一个数组来保存识别到的实体,每个实体包括其在文本中的开始和结束位置、类型和文本内容。

  3. 文本显示与交互: 使用一个可编辑的区域(如 contenteditable div 或者 textarea)来显示文本,使用户可以选中文本中的某一部分并标注为特定类型的实体。

  4. 文本选取事件处理: 当用户选取文本时,捕获选取的文本范围,并提供一个 UI 让用户选择这段文本的实体类型。

  5. 实体高亮显示: 将已标注的实体在文本中高亮显示。可以使用不同的颜色或背景色来表示不同类型的实体。

  6. 后端存储: 设计一个后端 API 来保存用户的标注数据。

  7. 命名实体识别服务集成 (可选): 若希望系统能自动标注或建议标注,可以集成一个命名实体识别服务,如 spaCy、Stanford NER、BERT 等模型。

  8. 组件化: 可考虑将标注功能封装为 Vue 组件,使其更具可复用性。

  9. 样式和用户体验: 利用 CSS 和 Vue 的动画功能优化标注工具的用户体验。确保工具的交互流畅、直观。

  10. 状态管理: 如果应用变得复杂,可以考虑使用 Vue 的状态管理库,如 Vuex,pinia来管理应用的状态。

  11. 测试与部署: 使用单元测试和端到端测试来确保标注工具的功能正确性。之后,可以将应用部署到你选择的服务器或云平台。

前端实现构想

  1. 项目结构:

    plaintext
    /src
    ├── assets
    ├── components
    │   ├── TextAnnotator.vue
    │   └── EntityLabel.vue
    ├── views
    │   └── Home.vue
    ├── App.vue
    └── main.js
  2. TextAnnotator 组件: 这个组件主要处理文本的显示、选取、高亮和标注。

    /src/components/TextAnnotator.vue:

    vue
    <template>
      <div ref="annotator" contenteditable="true" @mouseup="handleMouseUp">
        <!-- 这里可以使用 slots 或 v-for 遍历 entities 显示高亮区域 -->
      </div>
      <EntityLabel v-if="selectedText" :position="labelPosition" @label="handleLabel" />
    </template>
    
    <script>
    import EntityLabel from './EntityLabel.vue';
    
    export default {
      components: { EntityLabel },
      data() {
        return {
          selectedText: null,
          entities: [], // e.g., [{ start, end, type, text }, ...]
          labelPosition: { top: 0, left: 0 }
        };
      },
      methods: {
        handleMouseUp(event) {
          const selection = window.getSelection();
          if (selection.toString().trim()) {
            this.selectedText = {
              start: selection.anchorOffset,
              end: selection.focusOffset,
              text: selection.toString()
            };
            // 根据鼠标位置显示标签选择器
            this.labelPosition = { top: event.clientY, left: event.clientX };
          } else {
            this.selectedText = null;
          }
        },
        handleLabel(type) {
          this.entities.push({ ...this.selectedText, type });
          this.selectedText = null;
          // 更新文本显示,添加高亮等
        }
      }
    };
    </script>
  3. EntityLabel 组件: 这个组件会在用户选取文本后显示,让用户为选中的文本选择一个实体标签。

    /src/components/EntityLabel.vue:

    vue
    <template>
      <div :style="{ top: position.top + 'px', left: position.left + 'px' }" class="entity-label">
        <button v-for="type in entityTypes" :key="type" @click="label(type)">{{ type }}</button>
      </div>
    </template>
    
    <script>
    export default {
      props: {
        position: Object
      },
      data() {
        return {
          entityTypes: ['Person', 'Location', 'Organization', 'Date'] // 这里列出你需要的实体类型
        };
      },
      methods: {
        label(type) {
          this.$emit('label', type);
        }
      }
    };
    </script>
    
    <style scoped>
    .entity-label {
        position: absolute;
      z-index: 10;
      // 其他样式,如背景色、边框等
    }
    </style>
  4. 样式:

    使用 CSS 来高亮显示已标注的实体。例如,你可以根据不同的实体类型使用不同的背景色。

  5. 状态管理 (如果需要):

    如果文本标注功能非常复杂,建议使用 Vuex 进行状态管理。这样可以使得应用的状态更加清晰,也更便于维护。

  6. 其他功能:

    • 撤销/重做: 可以实现一个简单的操作历史功能,允许用户撤销或重做标注操作。
    • 保存: 设计一个按钮,当用户点击时将标注的数据保存到后端或导出为文件。
  7. 测试:

    可以使用 Vue Test Utils 和 Jest 进行单元测试,确保标注功能的正确性。

添加pure-admin支持

  1. 环境搭建:

    克隆 pure-admin 到本地,并安装其依赖:

    bash
    git clone https://github.com/xiaoxian521/vue-pure-admin.git
    cd vue-pure-admin
    yarn install
  2. 创建页面模块:

    src/views 下,你可以创建一个新的目录,如 text-annotation,并在其中创建你的 Vue 页面组件,如 Annotator.vue

  3. 更新路由:

    src/router/routes 中添加你的新页面路由,以便在 pure-admin 的侧边栏中显示你的标注页面链接。

  4. 实现 TextAnnotator 和 EntityLabel:

    src/components 下创建标注相关组件,如 TextAnnotator.vueEntityLabel.vue。你可以按照我之前提供的例子来实现这些组件的功能。

  5. 引入样式:

    src/styles 下可以增加一些你的标注工具所需的样式。确保它们与 pure-admin 的其他样式不冲突。

  6. 集成到页面:

    在你的 Annotator.vue 页面中引入和使用 TextAnnotatorEntityLabel 组件。

  7. 状态管理:

    如果需要,你可以在 src/store/modules 下添加一个新的 Vuex 模块来管理标注相关的状态。

  8. 后端集成:

    pure-admin 主要是前端项目,但它提供了与后端交互的 API 请求模板。你可以在 src/api 下添加你的 API 请求函数,用于与后端交互,如保存标注数据。

  9. 测试:

    pure-admin 使用 Vite 插件进行单元测试。你可以为你的标注组件添加相关的单元测试,确保它们工作正常。

TextAnnotator 组件细节

TextAnnotator 组件中,我们希望展示原始文本并高亮已经被标注的实体。为了实现这个功能,我们可以对原始文本进行分割,将非实体和实体分开,然后使用 v-for 循环进行遍历,对实体进行特殊样式的渲染。这样,被标注的实体就会以高亮的形式展示在文本中。

  1. 将原始文本分解为多个片段:

    可以将原始文本和 entities 数据结合起来,得到一个包含非实体和实体的片段数组。例如:

    javascript
    computed: {
      textFragments() {
        let fragments = [];
        let lastEnd = 0;
    
        this.entities.sort((a, b) => a.start - b.start).forEach(entity => {
          if (entity.start > lastEnd) {
            // 添加非实体片段
            fragments.push({
              type: 'text',
              content: this.originalText.substring(lastEnd, entity.start)
            });
          }
    
          // 添加实体片段
          fragments.push({
            type: 'entity',
            entityType: entity.type,
            content: entity.text
          });
    
          lastEnd = entity.end;
        });
    
        // 如果文本还有剩余部分,添加到末尾
        if (lastEnd < this.originalText.length) {
          fragments.push({
            type: 'text',
            content: this.originalText.substring(lastEnd)
          });
        }
    
        return fragments;
      }
    }
  2. 使用 v-for 展示文本片段:

    根据不同的片段类型(实体或非实体),你可以为其应用不同的样式。

    vue
    <template>
      <div ref="annotator" contenteditable="true" @mouseup="handleMouseUp">
        <span v-for="(fragment, index) in textFragments" :key="index"
              :class="fragment.type === 'entity' ? 'entity-highlight' : ''"
              :data-type="fragment.type === 'entity' ? fragment.entityType : ''">
          {{ fragment.content }}
        </span>
      </div>
    </template>
  3. 添加样式:

    根据不同的实体类型,为实体应用不同的高亮样式。例如:

    css
    .entity-highlight {
      background-color: #a0c4ff; /* 默认背景色 */
    }
    
    .entity-highlight[data-type="Person"] {
      background-color: #ffadad; /* 为人名实体应用特定的背景色 */
    }
    
    .entity-highlight[data-type="Location"] {
      background-color: #ffd6a5; /* 为地点实体应用特定的背景色 */
    }
    
    /* 其他实体类型的样式 */

这样,当 entities 数据发生变化时,高亮的实体将会自动更新。

mark标签优化

使用 <mark> 标签来高亮实体。它的语义化提供了更好的可读性。

  1. 文本片段分解

    与之前的方法相似,我们仍然需要分解原始文本,但这次我们会为实体部分使用 <mark> 标签。但在添加实体片段时,除了实体文本之外,还需要添加实体的类型说明。

    JavaScript
    computed: {
      textFragments() {
        let fragments = [];
        let lastEnd = 0;
    
        this.entities.sort((a, b) => a.start - b.start).forEach(entity => {
          if (entity.start > lastEnd) {
            // 添加非实体片段
            fragments.push({
              type: 'text',
              content: this.originalText.substring(lastEnd, entity.start)
            });
          }
    
          // 添加实体片段
          fragments.push({
            type: 'entity',
            entityType: entity.type,
            content: entity.text
          });
    
          lastEnd = entity.end;
        });
    
        // 如果文本还有剩余部分,添加到末尾
        if (lastEnd < this.originalText.length) {
          fragments.push({
            type: 'text',
            content: this.originalText.substring(lastEnd)
          });
        }
    
        return fragments;
    
      }
    }
  2. 在模板中使用 <mark> 标签包裹 <span>

    实体部分首先用 <span> 标签表示,然后在其后附加一个 <span> 标签来表示实体类型。将这两个 <span> 标签一起用一个 <mark> 标签包裹起来。

    vue
    <template>
      <div ref="annotator" contenteditable="true" @mouseup="handleMouseUp">
        <template v-for="(fragment, index) in textFragments">
          <span v-if="fragment.type === 'text'" :key="'text-' + index">
            {{ fragment.content }}
          </span>
          <mark v-if="fragment.type === 'entity'" :key="'entity-' + index">
            <span>{{ fragment.content }}</span>
            <span class="entity-type-label">{{ fragment.entityType }}</span>
          </mark>
        </template>
      </div>
    </template>
  3. 添加样式

    你可以为实体类型的 <span> 标签定义样式,例如改变其颜色或增加边框等,来使其更加明显。

    css
    .entity-type-label {
      color: #333;
      border: 1px solid #ddd;
      border-radius: 4px;
      padding: 2px 4px;
      margin-left: 5px;
      font-size: 80%;
    }
    
    mark {
      background-color: #a0c4ff;
    }

增加关系标注功能

TextAnnotator 组件添加关系标注功能相对复杂,因为它不仅需要处理文本标注,还需要处理实体间的关系。

  1. 数据结构更新

    首先,需要更新组件的数据结构来包含关系标注信息。除了 entities,还需要一个 relationships 数组。

    javascript
    data() {
      return {
        originalText: "",
        entities: [],
        relationships: [] // { sourceEntityId: 1, targetEntityId: 2, type: "朋友" }
      };
    }
  2. 关系标注模式

    为了标记关系,可能需要一种模式,让用户可以首先选择一个实体,然后选择另一个实体。我们可以通过引入一个 selectedEntityId 来处理这个模式。

    javascript
    data() {
      return {
        // ... 其他数据属性
        selectedEntityId: null
      };
    }
  3. 实体选择事件

    当用户点击一个实体时,更新 selectedEntityId

    javascript
    methods: {
      handleEntityClick(entityId) {
        if (!this.selectedEntityId) {
          this.selectedEntityId = entityId;
        } else {
          // 如果已经选择了一个实体,那么这是第二次点击,你可以为这两个实体创建一个关系
          this.relationships.push({
            sourceEntityId: this.selectedEntityId,
            targetEntityId: entityId,
            type: "朋友" // 这里你可能需要一个对话框或其他方式来让用户选择关系类型
          });
          this.selectedEntityId = null; // 重置选择
        }
      }
    }
  4. 显示关系

    在模板中需要为每个关系显示一个标签或箭头,标识它们之间的关系。这部分较为复杂,因为需要确定两个实体之间的位置,并在它们之间画一个线或箭头。

    可能需要使用一个图形库,如 D3.js,来绘制关系。

  5. 样式和交互

    当一个实体被选中等待与另一个实体建立关系时,需要改变其样式,以便用户知道他们已经选择了这个实体。

    css
    .entity-selected {
      border: 1px solid blue;
    }

    在模板中,要为选中的实体添加这个类。

    vue
    <mark v-if="fragment.type === 'entity'" 
          :key="'entity-' + index" 
          :class="'entity-' + fragment.entityType + (fragment.entityId === selectedEntityId ? ' entity-selected' : '')">
      <span>{{ fragment.content }}</span>
      <span class="entity-type-label">{{ fragment.entityType }}</span>
    </mark>

关系标注优化

文本往往在一个 textarea 内或在一系列文本行中,如果有多行文本之间绘制关系,标准的HTML/CSS可能无法满足在多行文本之间绘制连线的需求,这里参考了很多样例总结了一些方案:

  1. 使用Canvas或SVG

    可以使用HTML的 <canvas>或SVG来在文本上方层绘制连线。你首先需要确定每个实体在屏幕上的确切位置,可以通过使用 getBoundingClientRect() 方法来为HTML元素获取位置。

  2. 转变布局

    为了避免行之间的拥挤,可以考虑使用卡片或块状布局,而不是连续的文本流。这样,每个实体都可以在其自己的块中,与其他实体之间有足够的空间绘制连线。

  3. 使用浮动注释

    当用户点击一个实体时,可以在屏幕的某个固定位置(例如右侧或顶部)显示一个浮动面板。在这个面板上,用户可以选择与之关联的另一个实体。这样可以在这个浮动面板上绘制连线,而不是直接在文本上。

  4. 动态调整间距

    如果用户选择两个距离很远的实体,可以动态地为这两个实体之间的行增加额外的间距,以便有足够的空间绘制连线。当关系被取消或完成标注后,可以将间距还原到原始状态。

  5. 绘制曲线

    如果决定在文本行之间直接绘制连线,可以考虑使用曲线(例如贝塞尔曲线)代替直线。曲线可以更容易地避免与文本重叠,尤其是当关系的开始和结束实体位于特别远的行时——第一行和最后一行。

选用canvas或者svg方案

  1. 初始化SVG层

    在组件的模板中,需要增加一个 SVG 层,它应该位于文本之上,这样关系线条可以覆盖文本。

    vue
    <template>
        <div class="text-annotator-container" ref="annotatorContainer">
            <div ref="annotator" contenteditable="true" @mouseup="handleMouseUp">
                <!-- ... 你的其他代码 -->
            </div>
            <svg ref="svgLayer" class="svg-layer"></svg>
        </div>
    </template>

    对应的样式应该让 SVG 层覆盖文本:

    css
    .text-annotator-container {
        position: relative;
    }
    
    .svg-layer {
        position: absolute;
        top: 0;
        left: 0;
        width: 100%;
        height: 100%;
        pointer-events: none; /* 防止SVG捕获点击事件,使得文本下方仍然可选 */
    }
  2. 确定实体位置

    为了绘制两个实体间的线条,需要确定每个实体的位置。这可以通过 getBoundingClientRect() 实现。

    javascript
    methods: {
        getEntityPosition(entityId) {
            const entityElement = this.$refs.annotator.querySelector(`.entity-${entityId}`);
            return entityElement.getBoundingClientRect();
        },
    }
  3. 绘制关系

    每当 relationships 数据发生变化时,需要更新 SVG 层来显示新的关系线条。

    javascript
    methods: {
        drawRelationships() {
            const svg = this.$refs.svgLayer;
            svg.innerHTML = ''; // 清空先前的连线
    
            this.relationships.forEach(rel => {
                const sourcePos = this.getEntityPosition(rel.sourceEntityId);
                const targetPos = this.getEntityPosition(rel.targetEntityId);
    
                // 计算起点和终点的坐标
                const startX = sourcePos.left + sourcePos.width / 2;
                const startY = sourcePos.top + sourcePos.height / 2;
                const endX = targetPos.left + targetPos.width / 2;
                const endY = targetPos.top + targetPos.height / 2;
    
                // 创建一个 SVG 线元素来表示关系
                const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
                line.setAttribute('x1', startX);
                line.setAttribute('y1', startY);
                line.setAttribute('x2', endX);
                line.setAttribute('y2', endY);
                line.setAttribute('stroke', 'black');
                line.setAttribute('stroke-width', '2');
    
                // 将线条添加到 SVG 层中
                svg.appendChild(line);
            });
        },
    }
  4. 动态响应

    如果组件的尺寸发生变化,或者由于某种原因实体的位置变动,需要重新绘制关系线条。可以考虑在适当的时机(例如浏览器窗口大小改变时)调用 drawRelationships()

    javascript
    mounted() {
        window.addEventListener('resize', this.drawRelationships);
    },
    
    beforeDestroy() {
        window.removeEventListener('resize', this.drawRelationships);
    }
  5. 优化与交互

    • 考虑为关系线条添加颜色或动画,使其更具视觉吸引力。
    • 如果有多条关系线条,考虑使用贝塞尔曲线代替直线,避免线条交叉。
    • 考虑增加交互性,例如当用户悬停在一条关系线上时,显示关系的详细信息。

贡献者

凌晨三点的修狗