205 lines
4.2 KiB
Go
205 lines
4.2 KiB
Go
package pipeline
|
|
|
|
import (
|
|
"regexp"
|
|
"sort"
|
|
"strings"
|
|
"unicode"
|
|
)
|
|
|
|
// FeatureCount debe coincidir con models v1.1.0 (services-count-facturas-medicas-ml).
|
|
const FeatureCount = 14
|
|
|
|
var (
|
|
codePattern = regexp.MustCompile(`^\d{4,6}$`)
|
|
serviceCodePattern = regexp.MustCompile(`(?i)^(\d{4,8}|\d+-\d+|[A-Z]{2,}\d{2,})$`)
|
|
currencyPattern = regexp.MustCompile(`^\d{1,3}(,\d{3})+(\.\d+)?$`)
|
|
headerKeywordPattern = regexp.MustCompile(`(?i)\b(FECHA|VALOR|SUBTOTAL|CUFE|RESOLUCION|CONTRATO|FACTURA|NIT|TELEFONO|TOTAL)\b`)
|
|
)
|
|
|
|
type rowFeatures struct {
|
|
Features [FeatureCount]float32
|
|
TextoFila string
|
|
Cluster int
|
|
yNorm float32
|
|
// índices usados en el paso de continuación
|
|
hasServiceTablePattern int
|
|
currencyCount int
|
|
}
|
|
|
|
func isServiceCode(token string) bool {
|
|
return serviceCodePattern.MatchString(token)
|
|
}
|
|
|
|
func hasHeaderKeyword(text string) bool {
|
|
return headerKeywordPattern.MatchString(text)
|
|
}
|
|
|
|
func firstTokenOf(text string) string {
|
|
parts := strings.Fields(text)
|
|
if len(parts) == 0 {
|
|
return ""
|
|
}
|
|
return parts[0]
|
|
}
|
|
|
|
func extractRowFeatures(group []indexedBox, pageW, pageH int) rowFeatures {
|
|
sort.Slice(group, func(i, j int) bool { return group[i].X < group[j].X })
|
|
|
|
texts := make([]string, len(group))
|
|
for i, b := range group {
|
|
texts[i] = b.Text
|
|
}
|
|
textoFila := strings.Join(texts, " ")
|
|
firstToken := ""
|
|
if len(texts) > 0 {
|
|
firstToken = texts[0]
|
|
}
|
|
|
|
currencyCount := 0
|
|
integerCount := 0
|
|
for _, t := range texts {
|
|
if currencyPattern.MatchString(t) {
|
|
currencyCount++
|
|
}
|
|
if isDigits(t) {
|
|
integerCount++
|
|
}
|
|
}
|
|
|
|
xMin, xMax := group[0].X, group[0].X
|
|
ySum := 0
|
|
for _, b := range group {
|
|
if b.X < xMin {
|
|
xMin = b.X
|
|
}
|
|
if b.X > xMax {
|
|
xMax = b.X
|
|
}
|
|
ySum += b.Y
|
|
}
|
|
|
|
xSpanNorm := float32(0)
|
|
if pageW > 0 {
|
|
xSpanNorm = float32(xMax-xMin) / float32(pageW)
|
|
}
|
|
yNorm := float32(0)
|
|
if pageH > 0 {
|
|
yNorm = float32(ySum/len(group)) / float32(pageH)
|
|
}
|
|
firstTokenXNorm := float32(0)
|
|
if pageW > 0 {
|
|
firstTokenXNorm = float32(xMin) / float32(pageW)
|
|
}
|
|
|
|
tailStart := 0
|
|
if len(texts) >= 3 {
|
|
tailStart = len(texts) - 3
|
|
}
|
|
currencyAtRowEnd := 0
|
|
for _, t := range texts[tailStart:] {
|
|
if currencyPattern.MatchString(t) {
|
|
currencyAtRowEnd = 1
|
|
break
|
|
}
|
|
}
|
|
|
|
totalChars := len(textoFila)
|
|
if totalChars == 0 {
|
|
totalChars = 1
|
|
}
|
|
special := 0
|
|
digits := 0
|
|
for _, c := range textoFila {
|
|
if !unicode.IsLetter(c) && !unicode.IsNumber(c) && !unicode.IsSpace(c) {
|
|
special++
|
|
}
|
|
if unicode.IsDigit(c) {
|
|
digits++
|
|
}
|
|
}
|
|
|
|
firstIsCode := 0
|
|
if codePattern.MatchString(firstToken) {
|
|
firstIsCode = 1
|
|
}
|
|
|
|
hasTablePattern := 0
|
|
if currencyCount >= 2 && integerCount >= 1 {
|
|
hasTablePattern = 1
|
|
}
|
|
headerKW := 0
|
|
if hasHeaderKeyword(textoFila) {
|
|
headerKW = 1
|
|
}
|
|
|
|
return rowFeatures{
|
|
Features: [FeatureCount]float32{
|
|
float32(firstIsCode),
|
|
boolToFloat(isServiceCode(firstToken)),
|
|
float32(hasTablePattern),
|
|
float32(headerKW),
|
|
float32(currencyAtRowEnd),
|
|
firstTokenXNorm,
|
|
0, // is_likely_continuation — segundo paso
|
|
float32(currencyCount),
|
|
float32(integerCount),
|
|
xSpanNorm,
|
|
yNorm,
|
|
float32(len(texts)),
|
|
float32(special) / float32(totalChars),
|
|
float32(digits) / float32(totalChars),
|
|
},
|
|
TextoFila: textoFila,
|
|
Cluster: group[0].Cluster,
|
|
yNorm: yNorm,
|
|
hasServiceTablePattern: hasTablePattern,
|
|
currencyCount: currencyCount,
|
|
}
|
|
}
|
|
|
|
func boolToFloat(v bool) float32 {
|
|
if v {
|
|
return 1
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func buildFeatureRows(boxes []indexedBox, pageW, pageH int) []rowFeatures {
|
|
groups := groupByCluster(boxes)
|
|
rows := make([]rowFeatures, 0, len(groups))
|
|
for _, group := range groups {
|
|
rows = append(rows, extractRowFeatures(group, pageW, pageH))
|
|
}
|
|
if len(rows) == 0 {
|
|
return rows
|
|
}
|
|
|
|
sort.Slice(rows, func(i, j int) bool { return rows[i].yNorm < rows[j].yNorm })
|
|
|
|
for i := 1; i < len(rows); i++ {
|
|
prev := rows[i-1]
|
|
cur := &rows[i]
|
|
firstToken := firstTokenOf(cur.TextoFila)
|
|
if prev.hasServiceTablePattern == 1 &&
|
|
cur.currencyCount == 0 &&
|
|
!isServiceCode(firstToken) &&
|
|
!codePattern.MatchString(firstToken) {
|
|
cur.Features[6] = 1
|
|
}
|
|
}
|
|
return rows
|
|
}
|
|
|
|
func isDigits(s string) bool {
|
|
if s == "" {
|
|
return false
|
|
}
|
|
for _, c := range s {
|
|
if c < '0' || c > '9' {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|