hindi-normalize 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- hindi_normalize/__init__.py +56 -0
- hindi_normalize/devanagari.py +244 -0
- hindi_normalize/numbers.py +273 -0
- hindi_normalize/py.typed +0 -0
- hindi_normalize/repetition.py +47 -0
- hindi_normalize/terms.py +52 -0
- hindi_normalize/transcript.py +74 -0
- hindi_normalize-0.1.0.dist-info/METADATA +150 -0
- hindi_normalize-0.1.0.dist-info/RECORD +11 -0
- hindi_normalize-0.1.0.dist-info/WHEEL +4 -0
- hindi_normalize-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""
|
|
2
|
+
hindi_normalize — convert Hindi number words to digits and normalize Devanagari
|
|
3
|
+
ASR/OCR text. Zero dependencies beyond the Python standard library.
|
|
4
|
+
|
|
5
|
+
Quick start::
|
|
6
|
+
|
|
7
|
+
from hindi_normalize import normalize_transcript, convert_numbers
|
|
8
|
+
from hindi_normalize import normalize_devanagari, replace_terms
|
|
9
|
+
|
|
10
|
+
normalize_transcript("आपका BP एक सौ दस बटा सत्तर है") # 'आपका BP 110/70 है'
|
|
11
|
+
convert_numbers("नौ सौ निन्यानवे") # '999'
|
|
12
|
+
normalize_devanagari("वजन ५८ किलो | ठीक है") # 'वजन 58 किलो। ठीक है'
|
|
13
|
+
replace_terms("बीबी ठीक है") # 'BP ठीक है'
|
|
14
|
+
|
|
15
|
+
Building blocks (all importable from the top level):
|
|
16
|
+
|
|
17
|
+
* numbers — parse_hindi_number, convert_numbers, WORD_TO_NUM
|
|
18
|
+
* devanagari — normalize_devanagari, strip_zero_width, digit conversion,
|
|
19
|
+
normalize_danda, fold_nasals
|
|
20
|
+
* terms — replace_terms, MEDICAL_TERMS
|
|
21
|
+
* repetition — collapse_repetition
|
|
22
|
+
* transcript — normalize_transcript (batteries-included pipeline)
|
|
23
|
+
|
|
24
|
+
For invisible/typographic cleanup of general (non-Devanagari) text, compose with
|
|
25
|
+
``llmclean``.
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from .numbers import WORD_TO_NUM, parse_hindi_number, convert_numbers
|
|
29
|
+
from .devanagari import (
|
|
30
|
+
strip_zero_width,
|
|
31
|
+
devanagari_digits_to_ascii,
|
|
32
|
+
ascii_digits_to_devanagari,
|
|
33
|
+
normalize_danda,
|
|
34
|
+
fold_nasals,
|
|
35
|
+
normalize_devanagari,
|
|
36
|
+
)
|
|
37
|
+
from .terms import MEDICAL_TERMS, replace_terms
|
|
38
|
+
from .repetition import collapse_repetition
|
|
39
|
+
from .transcript import normalize_transcript
|
|
40
|
+
|
|
41
|
+
__all__ = [
|
|
42
|
+
"WORD_TO_NUM",
|
|
43
|
+
"parse_hindi_number",
|
|
44
|
+
"convert_numbers",
|
|
45
|
+
"strip_zero_width",
|
|
46
|
+
"devanagari_digits_to_ascii",
|
|
47
|
+
"ascii_digits_to_devanagari",
|
|
48
|
+
"normalize_danda",
|
|
49
|
+
"fold_nasals",
|
|
50
|
+
"normalize_devanagari",
|
|
51
|
+
"MEDICAL_TERMS",
|
|
52
|
+
"replace_terms",
|
|
53
|
+
"collapse_repetition",
|
|
54
|
+
"normalize_transcript",
|
|
55
|
+
]
|
|
56
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""devanagari.py — character-level normalization of Devanagari (Hindi) text.
|
|
2
|
+
|
|
3
|
+
Devanagari has several ways to encode the same visible text, and OCR/ASR/
|
|
4
|
+
copy-paste freely mix them. Left alone, these break equality checks, search,
|
|
5
|
+
regex, tokenization, and dedup — the string *looks* identical but isn't. This
|
|
6
|
+
module folds the common variants to a consistent form, following the operations
|
|
7
|
+
the Indic NLP Library's ``DevanagariNormalizer`` established as standard.
|
|
8
|
+
|
|
9
|
+
Handled:
|
|
10
|
+
|
|
11
|
+
* **Invisible / space characters.** Zero-width joiner/non-joiner, zero-width
|
|
12
|
+
space, BOM, soft hyphen, word joiner are removed; no-break space becomes a
|
|
13
|
+
regular space. On by default.
|
|
14
|
+
|
|
15
|
+
* **Encoding variants (NFC).** क़ can be one codepoint (U+0958) or क + nukta
|
|
16
|
+
(U+0915 U+093C), and matras can arrive in non-canonical order. ``compose``
|
|
17
|
+
applies Unicode NFC so both spellings become the same bytes. Lossless.
|
|
18
|
+
|
|
19
|
+
* **Devanagari digits.** ``५`` (U+096B) vs ``5``. ``digits`` maps Devanagari
|
|
20
|
+
digits to ASCII; :func:`ascii_digits_to_devanagari` does the reverse.
|
|
21
|
+
|
|
22
|
+
* **Danda punctuation.** ASR/OCR emit ASCII ``|`` where the danda ``।``
|
|
23
|
+
belongs, repeat it, or put a space before it. ``danda`` fixes all three.
|
|
24
|
+
|
|
25
|
+
* **Colon → visarga (opt-in).** ``दु:ख`` typed with an ASCII colon becomes
|
|
26
|
+
``दुःख``. Off by default — a colon after a Devanagari word is sometimes real
|
|
27
|
+
punctuation.
|
|
28
|
+
|
|
29
|
+
* **Nukta removal / nasal folding (opt-in, lossy).** ``remove_nukta`` folds
|
|
30
|
+
क़→क, ज़→ज; ``nasals`` folds न्, म् … clusters to anusvara (हिन्दी→हिंदी);
|
|
31
|
+
``chandrabindu`` folds ँ→ं. All off by default.
|
|
32
|
+
|
|
33
|
+
Unlike a naive ``unicodedata``-category filter, nothing here strips the Unicode
|
|
34
|
+
Mark category, so matras and the virama survive.
|
|
35
|
+
|
|
36
|
+
For general typographic punctuation (smart quotes, em dashes), compose this with
|
|
37
|
+
``llmclean``'s ``normalize_typography`` rather than duplicating it.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
from __future__ import annotations
|
|
41
|
+
|
|
42
|
+
import re
|
|
43
|
+
import unicodedata
|
|
44
|
+
|
|
45
|
+
__all__ = [
|
|
46
|
+
"strip_zero_width",
|
|
47
|
+
"devanagari_digits_to_ascii",
|
|
48
|
+
"ascii_digits_to_devanagari",
|
|
49
|
+
"normalize_danda",
|
|
50
|
+
"fold_nasals",
|
|
51
|
+
"normalize_devanagari",
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
_DEVA_RANGE = "ऀ-ॿ"
|
|
55
|
+
_NUKTA = "़"
|
|
56
|
+
_ANUSVARA = "ं"
|
|
57
|
+
_CHANDRABINDU = "ँ"
|
|
58
|
+
_VISARGA = "ः"
|
|
59
|
+
_DANDA = "।"
|
|
60
|
+
_DOUBLE_DANDA = "॥"
|
|
61
|
+
|
|
62
|
+
# ---------------------------------------------------------------------------
|
|
63
|
+
# Invisibles / spaces
|
|
64
|
+
# ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
_ZERO_WIDTH_TRANSLATE = {
|
|
67
|
+
0xFEFF: None, 0xFFFE: None, # BOM
|
|
68
|
+
0x200B: None, 0x200C: None, 0x200D: None, # ZWSP, ZWNJ, ZWJ
|
|
69
|
+
0x00AD: None, # soft hyphen
|
|
70
|
+
0x2060: None, # word joiner
|
|
71
|
+
0x00A0: 0x20, # NBSP → space
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def strip_zero_width(text: str) -> str:
|
|
76
|
+
"""Remove zero-width and joiner characters; map no-break space to space.
|
|
77
|
+
|
|
78
|
+
Follows the Indic NLP base normalization: strips ZWJ/ZWNJ/ZWSP, BOM, soft
|
|
79
|
+
hyphen, and word joiner, and turns a no-break space into a regular one.
|
|
80
|
+
Note this drops ZWJ/ZWNJ unconditionally, which can affect explicit conjunct
|
|
81
|
+
rendering; for Hindi normalization that is the accepted trade-off. Returns
|
|
82
|
+
the input unchanged when nothing matches; never raises.
|
|
83
|
+
"""
|
|
84
|
+
if not isinstance(text, str):
|
|
85
|
+
return text
|
|
86
|
+
try:
|
|
87
|
+
return text.translate(_ZERO_WIDTH_TRANSLATE)
|
|
88
|
+
except Exception:
|
|
89
|
+
return text
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
# ---------------------------------------------------------------------------
|
|
93
|
+
# Digits
|
|
94
|
+
# ---------------------------------------------------------------------------
|
|
95
|
+
|
|
96
|
+
_DEVA_DIGITS = "०१२३४५६७८९"
|
|
97
|
+
_ASCII_DIGITS = "0123456789"
|
|
98
|
+
_DEVA_TO_ASCII = {ord(d): a for d, a in zip(_DEVA_DIGITS, _ASCII_DIGITS)}
|
|
99
|
+
_ASCII_TO_DEVA = {ord(a): d for d, a in zip(_DEVA_DIGITS, _ASCII_DIGITS)}
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def devanagari_digits_to_ascii(text: str) -> str:
|
|
103
|
+
"""Map Devanagari digits (``०१२…``) to ASCII digits (``012…``).
|
|
104
|
+
|
|
105
|
+
Returns the input unchanged when it holds none; never raises.
|
|
106
|
+
"""
|
|
107
|
+
if not isinstance(text, str):
|
|
108
|
+
return text
|
|
109
|
+
try:
|
|
110
|
+
return text.translate(_DEVA_TO_ASCII)
|
|
111
|
+
except Exception:
|
|
112
|
+
return text
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def ascii_digits_to_devanagari(text: str) -> str:
|
|
116
|
+
"""Map ASCII digits (``012…``) to Devanagari digits (``०१२…``).
|
|
117
|
+
|
|
118
|
+
The inverse of :func:`devanagari_digits_to_ascii`; never raises.
|
|
119
|
+
"""
|
|
120
|
+
if not isinstance(text, str):
|
|
121
|
+
return text
|
|
122
|
+
try:
|
|
123
|
+
return text.translate(_ASCII_TO_DEVA)
|
|
124
|
+
except Exception:
|
|
125
|
+
return text
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# ---------------------------------------------------------------------------
|
|
129
|
+
# Danda punctuation
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
|
|
132
|
+
_PIPE_DOUBLE_RE = re.compile(r"\|\s*\|")
|
|
133
|
+
_PIPE_SINGLE_RE = re.compile(r"\|")
|
|
134
|
+
_DANDA_RUN_RE = re.compile(r"।(?:\s*।)+")
|
|
135
|
+
_SPACE_BEFORE_DANDA_RE = re.compile(r"\s+([।॥])")
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def normalize_danda(text: str) -> str:
|
|
139
|
+
"""Normalize danda punctuation.
|
|
140
|
+
|
|
141
|
+
Converts ASCII pipes used as dandas (``|``→``।``, ``||``→``॥``), collapses a
|
|
142
|
+
run of repeated dandas to one, and removes whitespace before a danda.
|
|
143
|
+
Returns the input unchanged when nothing matches; never raises.
|
|
144
|
+
"""
|
|
145
|
+
if not isinstance(text, str):
|
|
146
|
+
return text
|
|
147
|
+
try:
|
|
148
|
+
text = _PIPE_DOUBLE_RE.sub(_DOUBLE_DANDA, text)
|
|
149
|
+
text = _PIPE_SINGLE_RE.sub(_DANDA, text)
|
|
150
|
+
text = _DANDA_RUN_RE.sub(_DANDA, text)
|
|
151
|
+
text = _SPACE_BEFORE_DANDA_RE.sub(r"\1", text)
|
|
152
|
+
return text
|
|
153
|
+
except Exception:
|
|
154
|
+
return text
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ---------------------------------------------------------------------------
|
|
158
|
+
# Nukta / nasals / visarga
|
|
159
|
+
# ---------------------------------------------------------------------------
|
|
160
|
+
|
|
161
|
+
# Pancham (class-nasal) consonant + halant before a consonant = anusvara.
|
|
162
|
+
_PANCHAM = "ङञणनम"
|
|
163
|
+
_NASAL_CLUSTER_RE = re.compile(r"[" + _PANCHAM + r"]्(?=[क-ह])")
|
|
164
|
+
_COLON_VISARGA_RE = re.compile(r"([" + _DEVA_RANGE + r"]):")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def fold_nasals(text: str, *, chandrabindu: bool = False) -> str:
|
|
168
|
+
"""Fold explicit nasal-cluster spellings to anusvara (``ं``).
|
|
169
|
+
|
|
170
|
+
Rewrites a pancham consonant + halant before another consonant to anusvara
|
|
171
|
+
(``हिन्दी``→``हिंदी``). **Lossy** — discards which nasal was written. When
|
|
172
|
+
``chandrabindu`` is true, also folds ``ँ``→``ं``. Never raises.
|
|
173
|
+
"""
|
|
174
|
+
if not isinstance(text, str):
|
|
175
|
+
return text
|
|
176
|
+
try:
|
|
177
|
+
text = _NASAL_CLUSTER_RE.sub(_ANUSVARA, text)
|
|
178
|
+
if chandrabindu:
|
|
179
|
+
text = text.replace(_CHANDRABINDU, _ANUSVARA)
|
|
180
|
+
return text
|
|
181
|
+
except Exception:
|
|
182
|
+
return text
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _remove_nukta(text: str) -> str:
|
|
186
|
+
text = unicodedata.normalize("NFD", text).replace(_NUKTA, "")
|
|
187
|
+
return unicodedata.normalize("NFC", text)
|
|
188
|
+
|
|
189
|
+
|
|
190
|
+
# ---------------------------------------------------------------------------
|
|
191
|
+
# Orchestrator
|
|
192
|
+
# ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
_MULTISPACE_RE = re.compile(r"[^\S\n]{2,}")
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def normalize_devanagari(
|
|
198
|
+
text: str,
|
|
199
|
+
*,
|
|
200
|
+
zero_width: bool = True,
|
|
201
|
+
compose: bool = True,
|
|
202
|
+
digits: bool = True,
|
|
203
|
+
danda: bool = True,
|
|
204
|
+
visarga: bool = False,
|
|
205
|
+
remove_nukta: bool = False,
|
|
206
|
+
nasals: bool = False,
|
|
207
|
+
chandrabindu: bool = False,
|
|
208
|
+
collapse_space: bool = True,
|
|
209
|
+
) -> str:
|
|
210
|
+
"""Character-level normalization of Devanagari text.
|
|
211
|
+
|
|
212
|
+
Runs the enabled steps in order: strip invisibles (``zero_width``), NFC
|
|
213
|
+
(``compose``), nukta removal (``remove_nukta``), Devanagari→ASCII digits
|
|
214
|
+
(``digits``), danda punctuation (``danda``), colon→visarga (``visarga``),
|
|
215
|
+
nasal folding (``nasals``/``chandrabindu``), and space-run collapse
|
|
216
|
+
(``collapse_space``, newlines kept).
|
|
217
|
+
|
|
218
|
+
``zero_width``, ``compose``, ``digits``, ``danda``, ``collapse_space`` are on
|
|
219
|
+
by default (safe/lossless bar the accepted ZWJ trade-off). ``visarga``,
|
|
220
|
+
``remove_nukta``, ``nasals``, ``chandrabindu`` are lossy and off by default.
|
|
221
|
+
Never raises.
|
|
222
|
+
"""
|
|
223
|
+
if not isinstance(text, str):
|
|
224
|
+
return text
|
|
225
|
+
try:
|
|
226
|
+
if zero_width:
|
|
227
|
+
text = strip_zero_width(text)
|
|
228
|
+
if compose:
|
|
229
|
+
text = unicodedata.normalize("NFC", text)
|
|
230
|
+
if remove_nukta:
|
|
231
|
+
text = _remove_nukta(text)
|
|
232
|
+
if digits:
|
|
233
|
+
text = devanagari_digits_to_ascii(text)
|
|
234
|
+
if danda:
|
|
235
|
+
text = normalize_danda(text)
|
|
236
|
+
if visarga:
|
|
237
|
+
text = _COLON_VISARGA_RE.sub(r"\1" + _VISARGA, text)
|
|
238
|
+
if nasals or chandrabindu:
|
|
239
|
+
text = fold_nasals(text, chandrabindu=chandrabindu)
|
|
240
|
+
if collapse_space:
|
|
241
|
+
text = _MULTISPACE_RE.sub(" ", text)
|
|
242
|
+
return text
|
|
243
|
+
except Exception:
|
|
244
|
+
return text
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""numbers.py — convert Hindi number words to digits.
|
|
2
|
+
|
|
3
|
+
The core problem: a speech recogniser transcribes spoken Hindi numerals as
|
|
4
|
+
*words* ("एक सौ दस"), not digits ("110"), and downstream code that wants a BP
|
|
5
|
+
reading, a weight, or a dose needs the digits. This module maps Hindi number
|
|
6
|
+
words (0-999) to integers, tolerant of the spelling variants an ASR engine
|
|
7
|
+
produces ("पाँच"/"पांच"/"पाच", "सत्तर"/"सतर").
|
|
8
|
+
|
|
9
|
+
>>> convert_numbers("एक सौ दस बटा सत्तर")
|
|
10
|
+
'110 बटा 70'
|
|
11
|
+
>>> parse_hindi_number("नौ सौ निन्यानवे")
|
|
12
|
+
999
|
|
13
|
+
|
|
14
|
+
A deliberate design choice: adjacent *unrelated* number words are NOT summed.
|
|
15
|
+
"दो तीन दिन" ("two-three days") stays "2 3 दिन", never "5 दिन" — collapsing a
|
|
16
|
+
spoken range into its sum is a classic normalization bug.
|
|
17
|
+
"""
|
|
18
|
+
|
|
19
|
+
from __future__ import annotations
|
|
20
|
+
|
|
21
|
+
import re
|
|
22
|
+
from typing import Dict, List, Optional, Tuple
|
|
23
|
+
|
|
24
|
+
# Number words 0-100, including common ASR misspellings.
|
|
25
|
+
WORD_TO_NUM: Dict[str, int] = {
|
|
26
|
+
# 0-10
|
|
27
|
+
"शून्य": 0, "एक": 1, "दो": 2, "तीन": 3, "चार": 4,
|
|
28
|
+
"पांच": 5, "पाँच": 5, "पाच": 5, "छह": 6, "छः": 6,
|
|
29
|
+
"सात": 7, "आठ": 8, "नौ": 9, "दस": 10,
|
|
30
|
+
# 11-19
|
|
31
|
+
"ग्यारह": 11, "गयारह": 11, "ग्यारा": 11,
|
|
32
|
+
"बारह": 12, "बारा": 12,
|
|
33
|
+
"तेरह": 13, "तेरा": 13,
|
|
34
|
+
"चौदह": 14, "चौदा": 14,
|
|
35
|
+
"पंद्रह": 15, "पन्द्रह": 15, "पंद्रा": 15,
|
|
36
|
+
"सोलह": 16, "सोला": 16,
|
|
37
|
+
"सत्रह": 17, "सत्तरह": 17,
|
|
38
|
+
"अठारह": 18, "अठारा": 18,
|
|
39
|
+
"उन्नीस": 19, "उन्निस": 19,
|
|
40
|
+
# 20-29
|
|
41
|
+
"बीस": 20, "इक्कीस": 21, "इक्किस": 21,
|
|
42
|
+
"बाईस": 22, "बाइस": 22,
|
|
43
|
+
"तेईस": 23, "तेइस": 23,
|
|
44
|
+
"चौबीस": 24, "चौबिस": 24,
|
|
45
|
+
"पच्चीस": 25, "पचीस": 25, "पच्चिस": 25,
|
|
46
|
+
"छब्बीस": 26, "छब्बिस": 26,
|
|
47
|
+
"सत्ताईस": 27, "सत्ताइस": 27,
|
|
48
|
+
"अट्ठाईस": 28, "अट्ठाइस": 28, "अठ्ठाईस": 28,
|
|
49
|
+
"उनतीस": 29, "उन्तीस": 29,
|
|
50
|
+
# 30-39
|
|
51
|
+
"तीस": 30, "इकतीस": 31, "इकत्तीस": 31,
|
|
52
|
+
"बत्तीस": 32, "बतीस": 32,
|
|
53
|
+
"तैंतीस": 33, "तेंतीस": 33,
|
|
54
|
+
"चौंतीस": 34, "चौतीस": 34,
|
|
55
|
+
"पैंतीस": 35, "पेंतीस": 35,
|
|
56
|
+
"छत्तीस": 36, "छतीस": 36,
|
|
57
|
+
"सैंतीस": 37, "सेंतीस": 37,
|
|
58
|
+
"अड़तीस": 38, "अडतीस": 38,
|
|
59
|
+
"उनतालीस": 39, "उन्तालीस": 39,
|
|
60
|
+
# 40-49
|
|
61
|
+
"चालीस": 40, "चालिस": 40,
|
|
62
|
+
"इकतालीस": 41, "एकतालीस": 41,
|
|
63
|
+
"बयालीस": 42, "बयालिस": 42,
|
|
64
|
+
"तैंतालीस": 43, "तेंतालीस": 43,
|
|
65
|
+
"चौवालीस": 44, "चवालीस": 44,
|
|
66
|
+
"पैंतालीस": 45, "पेंतालीस": 45,
|
|
67
|
+
"छियालीस": 46, "छयालीस": 46,
|
|
68
|
+
"सैंतालीस": 47, "सेंतालीस": 47,
|
|
69
|
+
"अड़तालीस": 48, "अडतालीस": 48,
|
|
70
|
+
"उनचास": 49,
|
|
71
|
+
# 50-59
|
|
72
|
+
"पचास": 50,
|
|
73
|
+
"इक्यावन": 51,
|
|
74
|
+
"बावन": 52,
|
|
75
|
+
"तिरपन": 53, "तिरेपन": 53,
|
|
76
|
+
"चौवन": 54, "चौबन": 54,
|
|
77
|
+
"पचपन": 55,
|
|
78
|
+
"छप्पन": 56, "छपन": 56,
|
|
79
|
+
"सत्तावन": 57, "सतावन": 57,
|
|
80
|
+
"अट्ठावन": 58, "अठावन": 58, "अठ्ठावन": 58,
|
|
81
|
+
"उनसठ": 59,
|
|
82
|
+
# 60-69
|
|
83
|
+
"साठ": 60, "साट": 60,
|
|
84
|
+
"इकसठ": 61, "एकसठ": 61,
|
|
85
|
+
"बासठ": 62, "बासट": 62,
|
|
86
|
+
"तिरसठ": 63, "तिरेसठ": 63,
|
|
87
|
+
"चौंसठ": 64, "चौसठ": 64,
|
|
88
|
+
"पैंसठ": 65, "पेंसठ": 65,
|
|
89
|
+
"छियासठ": 66, "छयासठ": 66,
|
|
90
|
+
"सड़सठ": 67, "सडसठ": 67,
|
|
91
|
+
"अड़सठ": 68, "अडसठ": 68,
|
|
92
|
+
"उनहत्तर": 69, "उनहतर": 69,
|
|
93
|
+
# 70-79
|
|
94
|
+
"सत्तर": 70, "सतर": 70,
|
|
95
|
+
"इकहत्तर": 71, "इकहतर": 71,
|
|
96
|
+
"बहत्तर": 72, "बहतर": 72,
|
|
97
|
+
"तिहत्तर": 73, "तिहतर": 73,
|
|
98
|
+
"चौहत्तर": 74, "चौहतर": 74,
|
|
99
|
+
"पचहत्तर": 75, "पचहतर": 75,
|
|
100
|
+
"छिहत्तर": 76, "छिहतर": 76,
|
|
101
|
+
"सतहत्तर": 77, "सतहतर": 77,
|
|
102
|
+
"अठहत्तर": 78, "अठहतर": 78,
|
|
103
|
+
"उन्यासी": 79, "उनासी": 79, "उन्नासी": 79,
|
|
104
|
+
# 80-89
|
|
105
|
+
"अस्सी": 80, "अस्सि": 80,
|
|
106
|
+
"इक्यासी": 81, "एक्यासी": 81,
|
|
107
|
+
"बयासी": 82, "ब्यासी": 82,
|
|
108
|
+
"तिरासी": 83,
|
|
109
|
+
"चौरासी": 84,
|
|
110
|
+
"पचासी": 85,
|
|
111
|
+
"छियासी": 86, "छयासी": 86,
|
|
112
|
+
"सत्तासी": 87, "सतासी": 87,
|
|
113
|
+
"अट्ठासी": 88, "अठासी": 88,
|
|
114
|
+
"नवासी": 89, "नव्वासी": 89,
|
|
115
|
+
# 90-99
|
|
116
|
+
"नब्बे": 90, "नब्बें": 90,
|
|
117
|
+
"इक्यानवे": 91,
|
|
118
|
+
"बानवे": 92,
|
|
119
|
+
"तिरानवे": 93,
|
|
120
|
+
"चौरानवे": 94,
|
|
121
|
+
"पंचानवे": 95, "पचानवे": 95,
|
|
122
|
+
"छियानवे": 96,
|
|
123
|
+
"सत्तानवे": 97, "सतानवे": 97,
|
|
124
|
+
"अट्ठानवे": 98, "अठानवे": 98,
|
|
125
|
+
"निन्यानवे": 99, "निन्नानवे": 99,
|
|
126
|
+
# Hundred marker
|
|
127
|
+
"सौ": 100, "सो": 100,
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
# Longest-first for greedy matching.
|
|
131
|
+
_NUM_SORTED = sorted(WORD_TO_NUM.items(), key=lambda x: -len(x[0]))
|
|
132
|
+
|
|
133
|
+
_DEVA = r"ऀ-ॿ"
|
|
134
|
+
_NUM_WORD_INNER = r"(?:" + "|".join(re.escape(w) for w, _ in _NUM_SORTED) + r")"
|
|
135
|
+
|
|
136
|
+
# A run of number words with Devanagari-aware boundaries.
|
|
137
|
+
_NUM_SEQ_RE = re.compile(
|
|
138
|
+
r"(?<![" + _DEVA + r"])"
|
|
139
|
+
+ _NUM_WORD_INNER
|
|
140
|
+
+ r"(?:\s+" + _NUM_WORD_INNER + r")*"
|
|
141
|
+
+ r"(?![" + _DEVA + r"])"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
# ASR merges digit-word onto सौ ("एकसो" → "एक सो").
|
|
145
|
+
_COMPOUND_SPLITS = re.compile(
|
|
146
|
+
r"(एकसो|दोसो|तीनसो|चारसो|पांचसो|पाँचसो|छहसो|सातसो|आठसो|नौसो)"
|
|
147
|
+
)
|
|
148
|
+
_COMPOUND_SPLIT_MAP = {
|
|
149
|
+
"एकसो": "एक सो", "दोसो": "दो सो", "तीनसो": "तीन सो",
|
|
150
|
+
"चारसो": "चार सो", "पांचसो": "पांच सो", "पाँचसो": "पाँच सो",
|
|
151
|
+
"छहसो": "छह सो", "सातसो": "सात सो", "आठसो": "आठ सो", "नौसो": "नौ सो",
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def _parse_one_number(words: List[str], start: int) -> Tuple[int, Optional[int]]:
|
|
156
|
+
"""Parse one Hindi number expression starting at ``words[start]``.
|
|
157
|
+
|
|
158
|
+
Returns ``(consumed_word_count, value)``, or ``(0, None)`` if no number
|
|
159
|
+
begins at ``start``.
|
|
160
|
+
|
|
161
|
+
Recognized patterns::
|
|
162
|
+
|
|
163
|
+
[1-9] सौ [1-99] → एक सौ साठ = 160
|
|
164
|
+
[1-9] सौ → दो सौ = 200
|
|
165
|
+
सौ [1-99] → सौ दस = 110
|
|
166
|
+
सौ → सौ = 100
|
|
167
|
+
[0-99] → अट्ठावन = 58, दस = 10
|
|
168
|
+
|
|
169
|
+
Adjacent *simple* digits are NOT merged: ``"दो तीन"`` yields ``(1, 2)`` — the
|
|
170
|
+
caller advances and parses ``"तीन"`` separately.
|
|
171
|
+
"""
|
|
172
|
+
n = len(words)
|
|
173
|
+
if start >= n:
|
|
174
|
+
return 0, None
|
|
175
|
+
v0 = WORD_TO_NUM.get(words[start])
|
|
176
|
+
if v0 is None:
|
|
177
|
+
return 0, None
|
|
178
|
+
|
|
179
|
+
# Pattern: [1-9] सौ [optional 1-99]
|
|
180
|
+
if 1 <= v0 < 10 and start + 1 < n and WORD_TO_NUM.get(words[start + 1]) == 100:
|
|
181
|
+
total = v0 * 100
|
|
182
|
+
if start + 2 < n:
|
|
183
|
+
v2 = WORD_TO_NUM.get(words[start + 2])
|
|
184
|
+
if v2 is not None and 0 < v2 < 100:
|
|
185
|
+
return 3, total + v2
|
|
186
|
+
return 2, total
|
|
187
|
+
|
|
188
|
+
# Pattern: सौ [optional 1-99]
|
|
189
|
+
if v0 == 100:
|
|
190
|
+
if start + 1 < n:
|
|
191
|
+
v1 = WORD_TO_NUM.get(words[start + 1])
|
|
192
|
+
if v1 is not None and 0 < v1 < 100:
|
|
193
|
+
return 2, 100 + v1
|
|
194
|
+
return 1, 100
|
|
195
|
+
|
|
196
|
+
# Pattern: any single number word (0-99)
|
|
197
|
+
return 1, v0
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def parse_hindi_number(text: str) -> Optional[int]:
|
|
201
|
+
"""Parse a single Hindi number expression into an integer.
|
|
202
|
+
|
|
203
|
+
For sequences of *unrelated* numbers (e.g. ``"दो तीन"`` — two then three),
|
|
204
|
+
returns only the first parseable number (``2``). Use :func:`convert_numbers`
|
|
205
|
+
to handle mixed sequences within a real transcript.
|
|
206
|
+
|
|
207
|
+
Examples::
|
|
208
|
+
|
|
209
|
+
"एक सौ दस" → 110
|
|
210
|
+
"एक सौ पचपन" → 155
|
|
211
|
+
"दो सौ" → 200
|
|
212
|
+
"सौ" → 100
|
|
213
|
+
"सत्तर" → 70
|
|
214
|
+
"अट्ठावन" → 58
|
|
215
|
+
"नौ सौ निन्यानवे" → 999
|
|
216
|
+
|
|
217
|
+
Returns ``None`` for empty input or a leading non-number word. Never raises.
|
|
218
|
+
"""
|
|
219
|
+
if not isinstance(text, str):
|
|
220
|
+
return None
|
|
221
|
+
try:
|
|
222
|
+
words = text.strip().split()
|
|
223
|
+
if not words:
|
|
224
|
+
return None
|
|
225
|
+
consumed, val = _parse_one_number(words, 0)
|
|
226
|
+
if consumed == 0:
|
|
227
|
+
return None
|
|
228
|
+
return val
|
|
229
|
+
except Exception:
|
|
230
|
+
return None
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def convert_numbers(text: str) -> str:
|
|
234
|
+
"""Replace every Hindi number-word sequence in *text* with digit strings.
|
|
235
|
+
|
|
236
|
+
Within a matched sequence, numbers are parsed one at a time, so unrelated
|
|
237
|
+
adjacent number words (``"दो तीन"``) stay separate (``"2 3"``) instead of
|
|
238
|
+
summing.
|
|
239
|
+
|
|
240
|
+
Examples::
|
|
241
|
+
|
|
242
|
+
"एक सौ दस" → "110"
|
|
243
|
+
"एक सौ दस बटा सत्तर" → "110 बटा 70"
|
|
244
|
+
"अट्ठावन kg" → "58 kg"
|
|
245
|
+
"एकसो दस" → "110" (compound-split ASR artifact)
|
|
246
|
+
|
|
247
|
+
Returns the input unchanged when it holds no number words; never raises.
|
|
248
|
+
"""
|
|
249
|
+
if not isinstance(text, str):
|
|
250
|
+
return text
|
|
251
|
+
try:
|
|
252
|
+
# Pre-split merged compounds like "एकसो" → "एक सो".
|
|
253
|
+
text = _COMPOUND_SPLITS.sub(
|
|
254
|
+
lambda m: _COMPOUND_SPLIT_MAP.get(m.group(0), m.group(0)), text
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
def _replace(m: "re.Match[str]") -> str:
|
|
258
|
+
words = m.group(0).split()
|
|
259
|
+
out: List[str] = []
|
|
260
|
+
i = 0
|
|
261
|
+
while i < len(words):
|
|
262
|
+
consumed, val = _parse_one_number(words, i)
|
|
263
|
+
if consumed == 0:
|
|
264
|
+
out.append(words[i])
|
|
265
|
+
i += 1
|
|
266
|
+
else:
|
|
267
|
+
out.append(str(val))
|
|
268
|
+
i += consumed
|
|
269
|
+
return " ".join(out)
|
|
270
|
+
|
|
271
|
+
return _NUM_SEQ_RE.sub(_replace, text)
|
|
272
|
+
except Exception:
|
|
273
|
+
return text
|
hindi_normalize/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""repetition.py — collapse inline repetition artifacts from ASR output.
|
|
2
|
+
|
|
3
|
+
Speech recognisers stutter: they emit the same short cluster or word several
|
|
4
|
+
times in a row (``ठीकठीकठीकठीक``, ``है है है है``). This collapses those runs
|
|
5
|
+
back to a single instance.
|
|
6
|
+
|
|
7
|
+
This targets *inline, back-to-back* repeats anywhere in the text. For runaway
|
|
8
|
+
*tail* loops at the end of long LLM generations (repeated sentences or
|
|
9
|
+
paragraphs), reach for ``llmclean``'s ``trim_repetition`` instead — the two are
|
|
10
|
+
complementary.
|
|
11
|
+
|
|
12
|
+
>>> collapse_repetition("ठीकठीकठीकठीक है")
|
|
13
|
+
'ठीक है'
|
|
14
|
+
>>> collapse_repetition("हाँ हाँ हाँ हाँ जी")
|
|
15
|
+
'हाँ जी'
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import re
|
|
21
|
+
|
|
22
|
+
__all__ = ["collapse_repetition"]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
def collapse_repetition(text: str, *, min_repeats: int = 4) -> str:
|
|
26
|
+
"""Collapse back-to-back repeats of a short cluster or whole word.
|
|
27
|
+
|
|
28
|
+
A cluster of up to 5 characters or a whitespace-separated word repeated
|
|
29
|
+
``min_repeats`` or more times in a row collapses to one instance. Returns
|
|
30
|
+
the input unchanged when nothing repeats that often; never raises.
|
|
31
|
+
|
|
32
|
+
Parameters
|
|
33
|
+
----------
|
|
34
|
+
text:
|
|
35
|
+
Raw ASR text.
|
|
36
|
+
min_repeats:
|
|
37
|
+
Minimum consecutive repeats before collapsing (default 4).
|
|
38
|
+
"""
|
|
39
|
+
if not isinstance(text, str) or min_repeats < 2:
|
|
40
|
+
return text
|
|
41
|
+
try:
|
|
42
|
+
n = min_repeats - 1
|
|
43
|
+
text = re.sub(r"(.{1,5}?)\1{" + str(n) + r",}", r"\1", text)
|
|
44
|
+
text = re.sub(r"(\S+)(?:\s+\1){" + str(n) + r",}", r"\1", text)
|
|
45
|
+
return text
|
|
46
|
+
except Exception:
|
|
47
|
+
return text
|
hindi_normalize/terms.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""terms.py — map spoken/mis-recognized terms to canonical forms.
|
|
2
|
+
|
|
3
|
+
An ASR engine writes English clinical or technical terms inconsistently: some
|
|
4
|
+
in Latin (``BP``), some transliterated into Devanagari (``बीपी``, ``बीबी``),
|
|
5
|
+
some spelled several different ways. A small term dictionary maps every variant
|
|
6
|
+
to one canonical form.
|
|
7
|
+
|
|
8
|
+
:data:`MEDICAL_TERMS` ships a maternal/child-health (ASHA home-visit) vocabulary
|
|
9
|
+
as a ready default. Pass your own dictionary to :func:`replace_terms` for any
|
|
10
|
+
other domain — the replacement logic is domain-agnostic.
|
|
11
|
+
|
|
12
|
+
>>> replace_terms("बीबी एक सौ दस बटा सत्तर", MEDICAL_TERMS)
|
|
13
|
+
'BP एक सौ दस / सत्तर'
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from typing import Dict, Mapping
|
|
19
|
+
|
|
20
|
+
__all__ = ["MEDICAL_TERMS", "replace_terms"]
|
|
21
|
+
|
|
22
|
+
MEDICAL_TERMS: Dict[str, str] = {
|
|
23
|
+
"बीपी": "BP", "भीपी": "BP", "बीबी": "BP", "बी पी": "BP", "बी.पी.": "BP",
|
|
24
|
+
"एचबी": "Hb", "हबी": "Hb", "हीमोग्लोबिन": "Hb", "एच बी": "Hb",
|
|
25
|
+
"आईएफए": "IFA", "आई एफ ए": "IFA",
|
|
26
|
+
"टीटी": "TT", "टी टी": "TT",
|
|
27
|
+
"पीएचसी": "PHC", "पी एच सी": "PHC", "पीएचसे": "PHC",
|
|
28
|
+
"सीएचसी": "CHC", "सी एच सी": "CHC",
|
|
29
|
+
"बीसीजी": "BCG", "ओपीवी": "OPV", "हेप बी": "Hep-B",
|
|
30
|
+
"आईएमएनसीआई": "IMNCI",
|
|
31
|
+
"किलो": "kg", "किलोग्राम": "kg",
|
|
32
|
+
"बटा": "/", "बता": "/",
|
|
33
|
+
"दशमलव": ".", "दशम्लव": ".", "दशम्लफ": ".",
|
|
34
|
+
"डिग्री": "°",
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def replace_terms(text: str, terms: Mapping[str, str] = MEDICAL_TERMS) -> str:
|
|
39
|
+
"""Replace each key of *terms* in *text* with its value, longest key first.
|
|
40
|
+
|
|
41
|
+
Longest-first ordering stops a short key from firing inside a longer one.
|
|
42
|
+
Pass ``terms=None`` or ``{}`` to no-op. Returns the input unchanged when
|
|
43
|
+
nothing matches; never raises.
|
|
44
|
+
"""
|
|
45
|
+
if not isinstance(text, str) or not terms:
|
|
46
|
+
return text
|
|
47
|
+
try:
|
|
48
|
+
for spoken, canonical in sorted(terms.items(), key=lambda x: -len(x[0])):
|
|
49
|
+
text = text.replace(spoken, canonical)
|
|
50
|
+
return text
|
|
51
|
+
except Exception:
|
|
52
|
+
return text
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"""transcript.py — batteries-included normalization for Hindi ASR transcripts.
|
|
2
|
+
|
|
3
|
+
:func:`normalize_transcript` chains the package's transforms in the order that
|
|
4
|
+
works for real speech-to-text output — collapse stutter, canonicalize the
|
|
5
|
+
Devanagari, map domain terms, convert number words to digits, tidy the
|
|
6
|
+
punctuation — so a raw Whisper/ASR string comes out ready for downstream
|
|
7
|
+
extraction:
|
|
8
|
+
|
|
9
|
+
>>> normalize_transcript("आपका BP एक सौ दस बटा सत्तर है, वजन अट्ठावन kg")
|
|
10
|
+
'आपका BP 110/70 है, वजन 58 kg'
|
|
11
|
+
|
|
12
|
+
Each stage is a flag, and the term dictionary is swappable, so the pipeline
|
|
13
|
+
adapts to non-medical domains or to callers that only want part of it.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
import re
|
|
19
|
+
from typing import Mapping, Optional
|
|
20
|
+
|
|
21
|
+
from .devanagari import normalize_devanagari
|
|
22
|
+
from .numbers import convert_numbers
|
|
23
|
+
from .repetition import collapse_repetition
|
|
24
|
+
from .terms import MEDICAL_TERMS, replace_terms
|
|
25
|
+
|
|
26
|
+
__all__ = ["normalize_transcript"]
|
|
27
|
+
|
|
28
|
+
_SLASH_RE = re.compile(r"\s*/\s*")
|
|
29
|
+
_DECIMAL_RE = re.compile(r"(\d)\s*\.\s*(\d)")
|
|
30
|
+
_SENTENCE_RE = re.compile(r"[।](?:\s+)")
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def normalize_transcript(
|
|
34
|
+
transcript: str,
|
|
35
|
+
*,
|
|
36
|
+
terms: Optional[Mapping[str, str]] = MEDICAL_TERMS,
|
|
37
|
+
numbers: bool = True,
|
|
38
|
+
devanagari: bool = True,
|
|
39
|
+
repetition: bool = True,
|
|
40
|
+
sentence_breaks: bool = True,
|
|
41
|
+
) -> str:
|
|
42
|
+
"""Normalize a Hindi ASR transcript end to end.
|
|
43
|
+
|
|
44
|
+
Pipeline (each stage toggleable):
|
|
45
|
+
|
|
46
|
+
1. **repetition** — collapse ASR stutter (:func:`collapse_repetition`).
|
|
47
|
+
2. **devanagari** — invisibles, NFC, Devanagari→ASCII digits, danda
|
|
48
|
+
(:func:`normalize_devanagari`, safe defaults only).
|
|
49
|
+
3. **terms** — map domain terms to canonical forms (:func:`replace_terms`);
|
|
50
|
+
defaults to :data:`MEDICAL_TERMS`, pass ``None`` to skip.
|
|
51
|
+
4. **numbers** — Hindi number words → digits (:func:`convert_numbers`).
|
|
52
|
+
5. Spacing cleanup around ``/`` and decimal points.
|
|
53
|
+
6. **sentence_breaks** — newline after each danda ``।``.
|
|
54
|
+
|
|
55
|
+
Trailing punctuation and whitespace are always trimmed. Never raises.
|
|
56
|
+
"""
|
|
57
|
+
if not isinstance(transcript, str):
|
|
58
|
+
return transcript
|
|
59
|
+
try:
|
|
60
|
+
if repetition:
|
|
61
|
+
transcript = collapse_repetition(transcript)
|
|
62
|
+
if devanagari:
|
|
63
|
+
transcript = normalize_devanagari(transcript)
|
|
64
|
+
if terms:
|
|
65
|
+
transcript = replace_terms(transcript, terms)
|
|
66
|
+
if numbers:
|
|
67
|
+
transcript = convert_numbers(transcript)
|
|
68
|
+
transcript = _SLASH_RE.sub("/", transcript)
|
|
69
|
+
transcript = _DECIMAL_RE.sub(r"\1.\2", transcript)
|
|
70
|
+
if sentence_breaks:
|
|
71
|
+
transcript = _SENTENCE_RE.sub("।\n", transcript)
|
|
72
|
+
return transcript.strip().rstrip(",. ")
|
|
73
|
+
except Exception:
|
|
74
|
+
return transcript
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: hindi-normalize
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Convert Hindi number words to digits and normalize Devanagari ASR/OCR text
|
|
5
|
+
Project-URL: Homepage, https://github.com/Tushar-9802/hindi-normalize
|
|
6
|
+
Project-URL: Issues, https://github.com/Tushar-9802/hindi-normalize/issues
|
|
7
|
+
Author-email: Tushar Jaju <tusharbrisingr9802@gmail.com>
|
|
8
|
+
License: MIT License
|
|
9
|
+
|
|
10
|
+
Copyright (c) 2026 Tushar Jaju
|
|
11
|
+
|
|
12
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
13
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
14
|
+
in the Software without restriction, including without limitation the rights
|
|
15
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
16
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
17
|
+
furnished to do so, subject to the following conditions:
|
|
18
|
+
|
|
19
|
+
The above copyright notice and this permission notice shall be included in all
|
|
20
|
+
copies or substantial portions of the Software.
|
|
21
|
+
|
|
22
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
23
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
24
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
25
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
26
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
27
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
28
|
+
SOFTWARE.
|
|
29
|
+
License-File: LICENSE
|
|
30
|
+
Keywords: asr,devanagari,hindi,indic,nlp,normalization,number-words,speech-to-text,text-processing
|
|
31
|
+
Classifier: Development Status :: 3 - Alpha
|
|
32
|
+
Classifier: Intended Audience :: Developers
|
|
33
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
34
|
+
Classifier: Natural Language :: Hindi
|
|
35
|
+
Classifier: Programming Language :: Python :: 3
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.9
|
|
37
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
38
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
39
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
40
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
41
|
+
Classifier: Topic :: Software Development :: Libraries :: Python Modules
|
|
42
|
+
Classifier: Topic :: Text Processing :: Linguistic
|
|
43
|
+
Requires-Python: >=3.9
|
|
44
|
+
Provides-Extra: dev
|
|
45
|
+
Requires-Dist: pytest>=7.0; extra == 'dev'
|
|
46
|
+
Description-Content-Type: text/markdown
|
|
47
|
+
|
|
48
|
+
# hindi-normalize
|
|
49
|
+
|
|
50
|
+
Convert Hindi number words to digits and normalize Devanagari text from ASR, OCR, or copy-paste. Handles the encoding variants, punctuation quirks, and number-word spellings that break search, equality checks, tokenization, and downstream extraction. Zero runtime dependencies — pure standard library.
|
|
51
|
+
|
|
52
|
+
I built this because [Sakhi](https://github.com/Tushar-9802/Sakhi) — an offline medical Hindi voice-to-form tool meant for ASHAs— kept reinventing the same regex passes against Whisper output: number words that never became digits, `५` and `5` treated as different characters, `|` where a danda `।` belonged, the same word encoded two different ways. This is that pass, extracted and generalized.
|
|
53
|
+
|
|
54
|
+
## Install
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pip install hindi-normalize
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
## Quick start
|
|
61
|
+
|
|
62
|
+
```python
|
|
63
|
+
from hindi_normalize import normalize_transcript
|
|
64
|
+
|
|
65
|
+
normalize_transcript("आपका BP एक सौ दस बटा सत्तर है, वजन अट्ठावन kg")
|
|
66
|
+
# 'आपका BP 110/70 है, वजन 58 kg'
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
`normalize_transcript` runs the whole pipeline. The individual transforms are also exported and usable on their own.
|
|
70
|
+
|
|
71
|
+
## Numbers
|
|
72
|
+
|
|
73
|
+
Hindi number words → digits, tolerant of ASR spelling variants (`पाँच`/`पांच`/`पाच`, `सत्तर`/`सतर`). Adjacent unrelated numbers are not summed — `दो तीन दिन` ("two-three days") stays `2 3 दिन`, never `5`.
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from hindi_normalize import convert_numbers, parse_hindi_number
|
|
77
|
+
|
|
78
|
+
convert_numbers("एक सौ दस बटा सत्तर") # '110 बटा 70'
|
|
79
|
+
parse_hindi_number("नौ सौ निन्यानवे") # 999
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
## Devanagari
|
|
83
|
+
|
|
84
|
+
`normalize_devanagari` folds the character-level variants OCR/ASR/copy-paste introduce, following the operations the [Indic NLP Library](https://github.com/anoopkunchukuttan/indic_nlp_library) established as standard.
|
|
85
|
+
|
|
86
|
+
```python
|
|
87
|
+
from hindi_normalize import normalize_devanagari
|
|
88
|
+
|
|
89
|
+
normalize_devanagari("वजन ५८ किलो | ठीक है") # 'वजन 58 किलो। ठीक है'
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Default (lossless, on):
|
|
93
|
+
|
|
94
|
+
- **Invisibles** — strip ZWJ/ZWNJ/ZWSP, BOM, soft hyphen, word joiner; no-break space → space
|
|
95
|
+
- **NFC** — unify nukta encodings (`क़` as one codepoint vs `क` + nukta) and matra order
|
|
96
|
+
- **Digits** — Devanagari digits (`५`) → ASCII (`5`)
|
|
97
|
+
- **Danda** — `|` → `।`, `||` → `॥`, collapse runs, drop space before a danda
|
|
98
|
+
|
|
99
|
+
Opt-in (lossy, off):
|
|
100
|
+
|
|
101
|
+
- `remove_nukta` — `क़` → `क`, `ज़` → `ज`
|
|
102
|
+
- `nasals` — fold nasal clusters to anusvara (`हिन्दी` → `हिंदी`)
|
|
103
|
+
- `chandrabindu` — `ँ` → `ं`
|
|
104
|
+
- `visarga` — ASCII colon after a Devanagari letter → visarga (`दु:ख` → `दुःख`)
|
|
105
|
+
|
|
106
|
+
Unlike a naive `unicodedata`-category filter, nothing here strips the Unicode Mark category, so matras and the virama survive — the [failure mode](https://kavyamanohar.com/post/indic-normalizer/) that silently deletes vowel signs from Indic text.
|
|
107
|
+
|
|
108
|
+
## Terms
|
|
109
|
+
|
|
110
|
+
Map spoken/mis-recognized terms to canonical forms. Ships a maternal/child-health (ASHA home-visit) dictionary; pass your own for any domain.
|
|
111
|
+
|
|
112
|
+
```python
|
|
113
|
+
from hindi_normalize import replace_terms, MEDICAL_TERMS
|
|
114
|
+
|
|
115
|
+
replace_terms("बीबी एक सौ दस बटा सत्तर", MEDICAL_TERMS) # 'BP एक सौ दस / सत्तर'
|
|
116
|
+
replace_terms("क ख", {"क": "K", "ख": "KH"}) # 'K KH'
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Repetition
|
|
120
|
+
|
|
121
|
+
Collapse the back-to-back stutter recognisers emit.
|
|
122
|
+
|
|
123
|
+
```python
|
|
124
|
+
from hindi_normalize import collapse_repetition
|
|
125
|
+
|
|
126
|
+
collapse_repetition("ठीकठीकठीकठीक है") # 'ठीक है'
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
For runaway *tail* loops in long LLM generations (repeated sentences/paragraphs) and for general invisible/typographic cleanup of non-Devanagari text, compose with [llmclean](https://github.com/Tushar-9802/llmclean).
|
|
130
|
+
|
|
131
|
+
## API
|
|
132
|
+
|
|
133
|
+
| Function | Purpose |
|
|
134
|
+
|---|---|
|
|
135
|
+
| `normalize_transcript(text, *, terms, numbers, devanagari, repetition, sentence_breaks)` | Full pipeline |
|
|
136
|
+
| `convert_numbers(text)` | Hindi number words → digits |
|
|
137
|
+
| `parse_hindi_number(text)` | Parse one number expression → `int` |
|
|
138
|
+
| `normalize_devanagari(text, *, ...)` | Character-level normalization |
|
|
139
|
+
| `strip_zero_width(text)` | Remove invisibles; NBSP → space |
|
|
140
|
+
| `devanagari_digits_to_ascii(text)` / `ascii_digits_to_devanagari(text)` | Digit conversion |
|
|
141
|
+
| `normalize_danda(text)` | Danda punctuation |
|
|
142
|
+
| `fold_nasals(text, *, chandrabindu)` | Nasal clusters → anusvara |
|
|
143
|
+
| `replace_terms(text, terms)` | Term dictionary replacement |
|
|
144
|
+
| `collapse_repetition(text, *, min_repeats)` | Collapse inline stutter |
|
|
145
|
+
|
|
146
|
+
Every function is guarded against non-string input and returns the input unchanged rather than raising.
|
|
147
|
+
|
|
148
|
+
## License
|
|
149
|
+
|
|
150
|
+
MIT
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
hindi_normalize/__init__.py,sha256=a-aHUhIZ9nX6wuPP0DGax_W6t0aOgOuKdCpWeNIqU3o,2041
|
|
2
|
+
hindi_normalize/devanagari.py,sha256=RrpgZ7zeeaeEe2a6i-Si5tZ5mQoebPDnDtIu5k6E3N4,8766
|
|
3
|
+
hindi_normalize/numbers.py,sha256=STkWUrz5f_IhAbhyG9RFRmtWxZTpsXfZp1Bc9e3XnP4,11228
|
|
4
|
+
hindi_normalize/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
5
|
+
hindi_normalize/repetition.py,sha256=mdVSBuVc5nkDnHrF0W5SxG3QsQq2NjaoYvjmQXbit_0,1666
|
|
6
|
+
hindi_normalize/terms.py,sha256=noOFX9KLgT2bDJgF2DBvY6T-8_6eDksHeMbJuFCCCe8,2343
|
|
7
|
+
hindi_normalize/transcript.py,sha256=1As2OlMyz91SFWNAnhaO29uCLaG1xFpFao-SB6m77kQ,2818
|
|
8
|
+
hindi_normalize-0.1.0.dist-info/METADATA,sha256=te68zEuwcXZYe_Oytc6veB9DRDTr0PwHi_h2uHtrbQw,7246
|
|
9
|
+
hindi_normalize-0.1.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
10
|
+
hindi_normalize-0.1.0.dist-info/licenses/LICENSE,sha256=q3Y2X5F4Wb1XM3ApR2m8wHWa5GeDNP0z8Wi9HqnBUxk,1068
|
|
11
|
+
hindi_normalize-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tushar Jaju
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|