tien_nemo

demo2

......@@ -22,6 +22,7 @@ class OcrController extends Controller
'customer_name_xy' => 'required|string',
]);
$dataDetail = $request->fields ?? [];
$tableColumns = $request->table_columns ?? [];
try {
$masterTemplate = MstTemplate::updateOrCreate(
['tpl_name' => $request->template_name],
......@@ -49,6 +50,24 @@ class OcrController extends Controller
);
}
// Lưu mapping cột bảng (lưu col index vào field_xy với field_name đặc biệt)
if (!empty($tableColumns) && is_array($tableColumns)) {
foreach ($tableColumns as $name => $colIdx) {
if ($colIdx === null || $colIdx === '' || $colIdx === false) {
continue;
}
DtTemplate::updateOrInsert(
[
'tpl_id' => $masterTemplate->id,
'field_name' => '__table_col__' . $name,
],
[
'field_xy' => (string) $colIdx,
]
);
}
}
return response()->json([
'success' => true,
'message' => 'Lưu template thành công',
......@@ -70,8 +89,8 @@ class OcrController extends Controller
$templateName = $request->get('template_name', '');
// Giả sử file OCR JSON & ảnh nằm trong storage/app/public/image/
$jsonPath = public_path("image/3_1757295841_with_table.json");
$imgPath = ("image/3_1757295841.jpg");
$jsonPath = public_path("image/nemo_new_1757393338_with_table.json");
$imgPath = ("image/nemo_new_1757393338.jpg");
if (!file_exists($jsonPath)) {
return response()->json(['error' => 'File OCR JSON không tìm thấy'], 404);
......@@ -100,7 +119,13 @@ class OcrController extends Controller
// Lấy detail của template
$details = DtTemplate::where('tpl_id', $mst->id)->get();
$tableColumnMapping = [];
foreach ($details as $detail) {
if (strpos($detail->field_name, '__table_col__') === 0) {
$name = substr($detail->field_name, strlen('__table_col__'));
$tableColumnMapping[$name] = is_numeric($detail->field_xy) ? intval($detail->field_xy) : null;
continue;
}
$coords = array_map('intval', explode(',', $detail->field_xy));
// coords = [x1, y1, x2, y2]
......@@ -130,6 +155,7 @@ class OcrController extends Controller
'pdfImageUrl' => $imgPath,
'dataMapping' => $dataMapping,
'is_template' => $is_template,
'tableColumnMapping' => $tableColumnMapping ?? [], //?? new \stdClass(),
'fieldOptions' => [
[ 'value' => 'template_name', 'label' => 'Tên Mẫu PDF' ],
[ 'value' => 'customer_name', 'label' => 'Tên khách hàng' ],
......
......@@ -11,14 +11,14 @@ from PIL import Image, ImageEnhance
# ==== Config ====
BASE_DIR = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..", ".."))
# PDF_NAME = 'aaaa'
PDF_NAME = 'nemo_new'
# PDF path
pdf_path = Path(BASE_DIR) / "storage" / "pdf" / "3.pdf"
pdf_path = Path(BASE_DIR) / "storage" / "pdf" / "2.pdf"
# Output folder
output_folder = Path(BASE_DIR) / "public" / "image"
PDF_NAME = pdf_path.stem # Get the stem of the PDF file
# PDF_NAME = pdf_path.stem # Get the stem of the PDF file
#print(PDF_NAME)
os.makedirs(output_folder, exist_ok=True)
......@@ -151,9 +151,31 @@ for table in table_info:
x1, y1, x2, y2 = cell["cell"]
cell_texts = []
# Helper: compute overlap ratio of bbox against cell
def overlap_ratio(bbox, cell_box):
ix1 = max(bbox[0], cell_box[0])
iy1 = max(bbox[1], cell_box[1])
ix2 = min(bbox[2], cell_box[2])
iy2 = min(bbox[3], cell_box[3])
iw = max(0, ix2 - ix1)
ih = max(0, iy2 - iy1)
inter = iw * ih
bbox_area = max(1, (bbox[2] - bbox[0]) * (bbox[3] - bbox[1]))
return inter / float(bbox_area)
# Helper: check center inside cell
def center_inside(bbox, cell_box):
cx = (bbox[0] + bbox[2]) / 2.0
cy = (bbox[1] + bbox[3]) / 2.0
return (cx >= cell_box[0] and cx <= cell_box[2] and
cy >= cell_box[1] and cy <= cell_box[3])
cell_box = [x1, y1, x2, y2]
for item in ocr_data_list:
bx1, by1, bx2, by2 = item["bbox"]
if bx1 >= x1 and by1 >= y1 and bx2 <= x2 and by2 <= y2:
bbox = [bx1, by1, bx2, by2]
# Accept if bbox is largely inside the cell, or its center lies inside the cell
if overlap_ratio(bbox, cell_box) >= 0.3 or center_inside(bbox, cell_box):
cell_texts.append(item["text"])
# thêm vào cell gốc
......
......@@ -2,46 +2,73 @@ import cv2
import numpy as np
import os
def detect_tables(image_path):
def filter_horizontal_lines(lines_h, img_width, min_h_len_ratio=0.8, tol_y=10):
if lines_h is None:
return [], []
ys_candidates = []
for l in lines_h:
x1, y1, x2, y2 = l[0]
if abs(y1 - y2) <= 3: # ngang
line_len = abs(x2 - x1)
y_mid = int(round((y1 + y2) / 2))
ys_candidates.append((y_mid, line_len, x1, x2))
ys_candidates.sort(key=lambda x: x[0])
filtered_lines, line_segments, current_group = [], [], []
for y, length, x1, x2 in ys_candidates:
if not current_group:
current_group.append((y, length, x1, x2))
else:
if abs(y - current_group[-1][0]) <= tol_y:
current_group.append((y, length, x1, x2))
else:
longest = max(current_group, key=lambda x: x[1])
if longest[1] >= min_h_len_ratio * img_width:
filtered_lines.append(longest[0])
line_segments.append((longest[2], longest[3], longest[0]))
else:
break
current_group = [(y, length, x1, x2)]
if current_group:
longest = max(current_group, key=lambda x: x[1])
if longest[1] >= min_h_len_ratio * img_width:
filtered_lines.append(longest[0])
line_segments.append((longest[2], longest[3], longest[0]))
total_rows = max(0, len(filtered_lines) - 1)
print(f"Detected {total_rows} rows")
return filtered_lines, line_segments
def detect_tables(image_path, gap_threshold=50):
img = cv2.imread(image_path)
if img is None:
raise FileNotFoundError(f"Không đọc được ảnh: {image_path}")
gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
blur = cv2.GaussianBlur(gray, (3, 3), 0)
# Edge detection
edges = cv2.Canny(blur, 50, 150, apertureSize=3)
# --- Horizontal lines ---
lines_h = cv2.HoughLinesP(edges, 1, np.pi/180, threshold=120,
minLineLength=int(img.shape[1] * 0.6), maxLineGap=20)
ys_candidates, line_segments = [], []
if lines_h is not None:
for l in lines_h:
x1, y1, x2, y2 = l[0]
if abs(y1 - y2) <= 3: # ngang
y_mid = int(round((y1 + y2) / 2))
ys_candidates.append(y_mid)
line_segments.append((x1, x2, y_mid))
# gom nhóm y
ys, tol_y = [], 10
for y in sorted(ys_candidates):
if not ys or abs(y - ys[-1]) > tol_y:
ys.append(y)
img_height, img_width = img.shape[:2]
ys, line_segments = filter_horizontal_lines(lines_h, img_width, min_h_len_ratio=0.8, tol_y=10)
total_rows = max(0, len(ys) - 1)
# --- Vertical lines ---
lines_v = cv2.HoughLinesP(edges, 1, np.pi/180, threshold=100,
minLineLength=int(img.shape[0] * 0.5), maxLineGap=20)
xs = []
minLineLength=int(img.shape[0] * 0.4), maxLineGap=20)
v_lines, xs = [], []
if lines_v is not None:
for l in lines_v:
x1, y1, x2, y2 = l[0]
if abs(x1 - x2) <= 3:
xs.append(int(round((x1 + x2) / 2)))
v_lines.append((int(round((x1 + x2) / 2)), min(y1, y2), max(y1, y2)))
# gom nhóm x
x_pos, tol_v = [], 10
......@@ -50,26 +77,66 @@ def detect_tables(image_path):
x_pos.append(v)
total_cols = max(0, len(x_pos) - 1)
tables = []
if total_rows > 0 and total_cols > 0:
y_min, y_max = ys[0], ys[-1]
x_min, x_max = x_pos[0], x_pos[-1]
table_box = (x_min, y_min, x_max, y_max)
# build cells
rows_data = []
for i in range(total_rows):
row_cells = []
for j in range(total_cols):
j = 0
while j < total_cols:
cell_box = (x_pos[j], ys[i], x_pos[j+1], ys[i+1])
row_height = cell_box[3] - cell_box[1]
# Check vertical line coverage (>=70% chiều cao hàng)
has_left = any(
abs(x - cell_box[0]) <= tol_v and
(min(y_end, cell_box[3]) - max(y_start, cell_box[1])) >= 0.7 * row_height
for x, y_start, y_end in v_lines
)
has_right = any(
abs(x - cell_box[2]) <= tol_v and
(min(y_end, cell_box[3]) - max(y_start, cell_box[1])) >= 0.7 * row_height
for x, y_start, y_end in v_lines
)
if has_left and has_right:
col_start = j
col_end = j
# nếu cột tiếp theo không có line → merge
while col_end + 1 < total_cols:
next_box = (x_pos[col_end+1], ys[i], x_pos[col_end+2], ys[i+1])
has_next_left = any(
abs(x - next_box[0]) <= tol_v and
(min(y_end, next_box[3]) - max(y_start, next_box[1])) >= 0.7 * row_height
for x, y_start, y_end in v_lines
)
if not has_next_left: # merge tiếp
col_end += 1
else:
break
merged_box = (x_pos[col_start], ys[i], x_pos[col_end+1], ys[i+1])
if col_start == col_end:
col_id = col_start
else:
col_id = f"{col_start}-{col_end}"
row_cells.append({
"cell": cell_box,
"cell": merged_box,
"row_idx": i,
"col_idx": j
"col_idx": col_id
})
# Vẽ ô
cv2.rectangle(img, (cell_box[0], cell_box[1]), (cell_box[2], cell_box[3]), (0, 255, 255), 1)
cv2.rectangle(img, (merged_box[0], merged_box[1]),
(merged_box[2], merged_box[3]), (0, 255, 255), 1)
j = col_end + 1
else:
j += 1 # skip ô lỗi (không có line đầy đủ)
rows_data.append(row_cells)
tables.append({
......@@ -78,11 +145,9 @@ def detect_tables(image_path):
"table_box": table_box,
"cells": rows_data
})
# vẽ viền bảng
cv2.rectangle(img, (x_min, y_min), (x_max, y_max), (255, 0, 0), 2)
debug_path = os.path.splitext(image_path)[0] + "_debug.jpg"
debug_path = os.path.splitext(image_path)[0] + "_fix_debug.jpg"
cv2.imwrite(debug_path, img)
return tables
......
......@@ -79,7 +79,7 @@
@change="applyMapping"
>
<option disabled value="">-- Chọn trường dữ liệu --</option>
<option v-for="field in fieldData" :value="field.value">@{{ field.label }}</option>
<option v-for="field in getAllowedOptionsForBox(ocrData[selectingIndex])" :value="field.value">@{{ field.label }}</option>
</select>
<!-- Dropdown thủ công -->
......@@ -93,7 +93,7 @@
@click.stop
>
<option disabled value="">-- Chọn trường dữ liệu --</option>
<option v-for="field in fieldData" :value="field.value">@{{ field.label }}</option>
<option v-for="field in getAllowedOptionsForManual()" :value="field.value">@{{ field.label }}</option>
</select>
</div>
</div>
......@@ -118,24 +118,30 @@
</thead>
<tbody>
<tr class="table-detail"
v-for="(row, rowIndex) in total_rows"
v-for="(row, rowIndex) in dataRowsCount"
:key="rowIndex"
:data-row="rowIndex"
>
<td class="form-group">
<input
@click="onTableInputClick('product_name', rowIndex)"
v-model="tableForm[rowIndex].product_name"
:data-row="rowIndex"
:data-field="'product_name'"
placeholder="Enter name">
</td>
<td class="form-group">
<input
@click="onTableInputClick('product_code', rowIndex)"
v-model="tableForm[rowIndex].product_code"
:data-row="rowIndex"
:data-field="'product_code'"
placeholder="Enter code">
</td>
<td class="form-group">
<input
@click="onTableInputClick('quantity', rowIndex)"
v-model.number="tableForm[rowIndex].quantity"
:data-row="rowIndex"
:data-field="'quantity'"
type="number"
......@@ -166,8 +172,11 @@
tableInfo: {},
total_rows: 0,
table_box: [],
tableColumnMapping: { product_name: null, product_code: null, quantity: null },
tableForm: [],
manualBoxData: {},
fieldOptions: [],
tempFocusIndex: null,
customer_name_xy: '',
hasCustomerNameXY: false,
ocrData: [],
......@@ -220,9 +229,156 @@
fieldData() {
// Lọc bỏ template_name nếu không cần cho phần form mapping
return this.fieldOptions.filter(f => f.value !== "template_name");
},
dataRowsCount() {
return this.total_rows || 0;
}
},
methods: {
onTableInputClick(field, uiRowIndex) {
// Determine column from saved mapping
const colIdx = this.tableColumnMapping ? this.tableColumnMapping[field] : null;
if (colIdx === null || colIdx === undefined) return;
// uiRowIndex starts at 0 for first data row; tableInfo row_idx=uiRowIndex+1
const tableRowIdx = (uiRowIndex + 1);
if (!Array.isArray(this.tableInfo) || !this.tableInfo[tableRowIdx]) return;
const row = this.tableInfo[tableRowIdx];
const cell = row.find(c => c && c.col_idx === colIdx);
if (!cell || !Array.isArray(cell.cell)) return;
const coords = cell.cell; // [x1,y1,x2,y2]
// Remove previous temp focus
if (this.tempFocusIndex !== null && this.ocrData[this.tempFocusIndex]) {
this.$set(this.ocrData[this.tempFocusIndex], 'isDeleted', true);
this.tempFocusIndex = null;
}
// Create a temporary manual focus box for highlighting
const tmpBox = {
text: '',
bbox: coords,
field: field,
isManual: true,
showDelete: false,
isDeleted: false,
hideBorder: false
};
this.ocrData.push(tmpBox);
this.tempFocusIndex = this.ocrData.length - 1;
this.activeIndex = this.tempFocusIndex;
this.selectingIndex = null;
// Ensure it renders
this.$nextTick(() => this.$forceUpdate());
},
ensureTableFormSize() {
const rows = this.dataRowsCount;
if (!Array.isArray(this.tableForm)) this.tableForm = [];
for (let i = 0; i < rows; i++) {
if (!this.tableForm[i]) {
this.$set(this.tableForm, i, { product_name: '', product_code: '', quantity: null });
}
}
if (this.tableForm.length > rows) {
this.tableForm.splice(rows);
}
},
getAllowedOptionsForBox(item) {
const isInTable = this.isBboxInTable(item.bbox);
if (isInTable) {
return [
{ value: 'product_name', label: 'Product Name' },
{ value: 'product_code', label: 'Product Code' },
{ value: 'quantity', label: 'Quantity' },
];
}
return this.fieldData;
},
getAllowedOptionsForManual() {
// Use current selection box to determine
const origBbox = this.getCurrentOrigSelectBbox();
const isInTable = origBbox ? this.isBboxInTable(origBbox) : false;
if (isInTable) {
return [
{ value: 'product_name', label: 'Product Name' },
{ value: 'product_code', label: 'Product Code' },
{ value: 'quantity', label: 'Quantity' },
];
}
return this.fieldData;
},
getCurrentOrigSelectBbox() {
if (!this.$refs.pdfImage || !this.imageWidth || !this.imageHeight) return null;
const displayedWidth = this.$refs.pdfImage.clientWidth;
const displayedHeight = this.$refs.pdfImage.clientHeight;
const scaleX = this.imageWidth / displayedWidth;
const scaleY = this.imageHeight / displayedHeight;
return [
Math.round(this.selectBox.x * scaleX),
Math.round(this.selectBox.y * scaleY),
Math.round((this.selectBox.x + this.selectBox.width) * scaleX),
Math.round((this.selectBox.y + this.selectBox.height) * scaleY)
];
},
isBboxInTable(bbox) {
if (!this.table_box || this.table_box.length !== 4) return false;
return this.isBoxInside(bbox, this.table_box);
},
getColumnIndexForBbox(bbox) {
// Determine which column this bbox belongs to using table header row (row_idx = 0)
if (!Array.isArray(this.tableInfo) || this.tableInfo.length === 0) return null;
const headerRow = this.tableInfo[0];
if (!Array.isArray(headerRow)) return null;
// Use x-overlap with header cells to pick the best matching column
let bestCol = null;
let bestOverlap = 0;
const [bx1, by1, bx2, by2] = bbox;
headerRow.forEach(cell => {
if (!cell || !Array.isArray(cell.cell)) return;
const [cx1, cy1, cx2, cy2] = cell.cell;
const overlapX = Math.max(0, Math.min(bx2, cx2) - Math.max(bx1, cx1));
const headerWidth = cx2 - cx1;
const ratio = headerWidth > 0 ? overlapX / headerWidth : 0;
if (ratio > bestOverlap) {
bestOverlap = ratio;
bestCol = cell.col_idx;
}
});
if (bestOverlap > 0) return bestCol;
return null;
},
populateTableColumn(field, colIdx) {
// Fill tableForm values from table cells for the given column index, skipping header (row_idx 0)
this.ensureTableFormSize();
if (!Array.isArray(this.tableInfo)) return;
for (let r = 1; r < this.tableInfo.length; r++) {
const row = this.tableInfo[r];
const dataRowIndex = r - 1; // because header is row 0
if (!row || !Array.isArray(row)) continue;
const cell = row.find(c => c && c.col_idx === colIdx);
const value = cell ? (cell.text || (Array.isArray(cell.texts) ? cell.texts.join(' ') : '')) : '';
if (!this.tableForm[dataRowIndex]) {
this.$set(this.tableForm, dataRowIndex, { product_name: '', product_code: '', quantity: null });
}
if (field === 'quantity') {
const num = value ? Number((value + '').replace(/[^0-9.-]/g, '')) : null;
this.$set(this.tableForm[dataRowIndex], field, isNaN(num) ? null : num);
} else {
this.$set(this.tableForm[dataRowIndex], field, (value || '').toString());
}
}
},
applyTableMappingIfInTable(itemField, bbox) {
if (!itemField) return false;
if (!this.isBboxInTable(bbox)) return false;
// Restrict to table fields only
if (!['product_name','product_code','quantity'].includes(itemField)) return false;
const colIdx = this.getColumnIndexForBbox(bbox);
console.log(`Mapping field "${itemField}" to table column index:`, colIdx);
if (colIdx === null || colIdx === undefined) return false;
this.$set(this.tableColumnMapping, itemField, colIdx);
this.populateTableColumn(itemField, colIdx);
return true;
},
startResize(e, index, handle) {
e.stopPropagation();
this.resizing = {
......@@ -505,6 +661,13 @@
this.activeIndex = null;
this.selectingIndex = null;
// Cleanup temporary focus box if any
if (this.tempFocusIndex !== null) {
const box = this.ocrData[this.tempFocusIndex];
if (box) this.$set(box, 'isDeleted', true);
this.tempFocusIndex = null;
}
// Không ẩn nút xóa các box để tránh nhầm lẫn
// Chỉ reset focus
},
......@@ -671,16 +834,34 @@
}
});
if (item.isManual) {
console.log('aaaaaaaaaa')
// Nếu là manual box, chuyển sang chế độ manual mapping
this.manualIndex = this.selectingIndex;
this.manualField = item.field || "";
// Nếu trong vùng bảng, xử lý theo bảng trước
const handled = this.applyTableMappingIfInTable(this.manualField, this.ocrData[this.manualIndex].bbox);
if (!handled) {
this.applyManualMapping();
} else {
// reset selectingIndex if handled by table mapping
this.selectingIndex = null;
this.selectBox.show = false;
this.selectBox.showDropdown = false;
this.isMappingManually = false;
this.manualField = "";
this.manualIndex = null;
}
return;
}
// Xử lý box OCR (có thể chưa có field hoặc đã có field)
if (item.field) {
// Nếu trong vùng bảng, xử lý theo bảng
const handled = this.applyTableMappingIfInTable(item.field, item.bbox);
console.log(`applyMapping: field=${item.field}, handled by table=${handled}`);
if (handled) {
this.selectingIndex = null;
return;
}
// Cập nhật formData để hiển thị trong input
this.formData[item.field] = item.text || '';
......@@ -706,6 +887,17 @@
const newBbox = this.ocrData[manualIndex].bbox;
// Nếu trong vùng bảng, ưu tiên xử lý bảng và thoát
const handled = this.applyTableMappingIfInTable(this.manualField, newBbox);
if (handled) {
this.isMappingManually = false;
this.selectBox.show = false;
this.selectBox.showDropdown = false;
this.manualField = "";
this.manualIndex = null;
return;
}
//console.log(`manual for field "${this.manualField}" at index ${manualIndex} with bbox:`, newBbox);
this.ocrData.forEach((box, i) => {
if (i !== manualIndex && box.field === this.ocrData[manualIndex].field) {
......@@ -885,6 +1077,17 @@
this.fieldOptions = data.fieldOptions;
this.dataMapping = data.dataMapping;
this.is_template = data.is_template;
this.tableColumnMapping = data.tableColumnMapping || { product_name: null, product_code: null, quantity: null };
// Prepare table form structure
this.ensureTableFormSize();
// If template provides column mapping, populate values now
Object.keys(this.tableColumnMapping || {}).forEach(k => {
const col = this.tableColumnMapping[k];
if (col !== null && col !== undefined) {
this.populateTableColumn(k, col);
}
});
//console.log('Loaded OCR data:', this.ocrData);
} catch (error) {
......@@ -896,6 +1099,7 @@
let customer_name = null;
let customer_coords = null;
let fields = [];
const table_columns = this.tableColumnMapping || {};
if (this.manualBoxData.customer_name) {
// Lấy từ manualBoxData nếu có
customer_name = this.manualBoxData.customer_name.text;
......@@ -931,7 +1135,8 @@
customer_name_text: customer_name || '',
template_name: this.formData.template_name || this.formData.customer_name,
customer_name_xy: customer_coords || [],
fields: fields
fields: fields,
table_columns: table_columns
};
try {
......