Skip to content
Snippets Groups Projects

Vylepšení skenování - prázdné stránky, otáčení, vylepšení UI

Merged Jiří Setnička requested to merge jirka/scans into devel
All threads resolved!
1 file
+ 31
3
Compare changes
  • Side-by-side
  • Inline
  • Navazujeme na vyprahovaný černobílý obrázek, který používá detekce QR
    kódů. Pokud žádný QR kód nenajdeme, tak provádíme:
    
    * ořez 10 pixelů z každé strany pro odstranění divných hran ze skeneru
    * jeden krok eroze maticí 3x3 pro odstranění šumu a smetí na skeneru
      (pro každý pixel vezmeme maximum z jeho 3x3 okolí = eroze černých oblastí)
    * spočítáme entropii obrázku (skrze metodu PIL.Image.entropy())
    
    Pokud spočítaná entropie překročí threshold 0.005, tak stránku
    odhadneme za plnou, jinak ji odhadneme za prázdnou.
    
    Testování thresholdu na Xerox scanneru, seřazené podle entropie:
    * prázdná úplně bílá stránka (uměle vyrobená): 0.0
    * prázdná nezmačkaná stránka: 0.0000054647
    * prázdná zmačkaná a natržená stránka: 0.0000314348
    * vygenerované PDF s hlavičkou protokolu (neprošlo skenem, čisté PDF): 0.0166886208
    * sken ručních zápisků na kostičkovaný papír (popsaná 1/4 stránky): 0.0764304045
    * vybledlá pomačkaná faktura vytisklá s docházejícím tonerem: 0.0833685388
    * sken ručních zápisků na kostičkovaný papír (popsána 1/2 stránky): 0.1503896076
    * nějaký vyplněný formulář (tisk + propiska, nezmačkaný): 0.2290705466
    * sken ručních zápisků na kostičkovaný papír (popsána celá stránka, hodně škrtání): 0.2393648031
    
    Zatím není informace využitá, použije se v dalších commitech.
+ 94
29
# Implementace jobů na práci s protokoly
from PIL import Image
from PIL import Image, ImageFilter
from dataclasses import dataclass, field
import multiprocessing
import os
@@ -20,6 +20,12 @@ from mo.jobs import TheJob, job_handler
from mo.util import logger, part_path, tex_arg
import mo.util_format
SCAN_BW_THRESHOLD = 180 # 0-255, pixel nad toto číslo = bílý pixel
# Parametry pro detekci prázdných stránek:
EMPTY_PAGE_CROP = 10 # kolik px z každé strany uříznout
EMPTY_PAGE_ERODE_SIZE = 3 # jak velkou maticí K×K erodovat
EMPTY_PAGE_ERODE_COUNT = 1 # kolikrát erodovat
EMPTY_PAGE_ENTROPY_THRESHOLD = 0.005 # Image.entropy() menší než X → prázdná stránka
#
# Job create_protocols: Vygeneruje formuláře protokolů
@@ -172,7 +178,10 @@ def handle_create_protocols(the_job: TheJob):
#
def schedule_process_scans(contest: db.Contest, site: Optional[db.Place], scans_type: str, for_user: db.User, tasks: List[db.Task], in_file_names: List[str]) -> int:
def schedule_process_scans(
contest: db.Contest, site: Optional[db.Place], scans_type: str, for_user: db.User,
tasks: List[db.Task], in_file_names: List[str], double_sided: bool
) -> int:
place = site or contest.place
scans_desc = "odevzdaných řešení" if scans_type == "solution" else "oprav"
@@ -195,6 +204,7 @@ def schedule_process_scans(contest: db.Contest, site: Optional[db.Place], scans_
'site_id': site.place_id if site else None,
'task_ids': [t.task_id for t in tasks],
'in_files': in_files,
'double_sided': double_sided,
}
the_job.submit()
assert the_job.job_id is not None
@@ -210,6 +220,8 @@ class ScanJobArgs:
@dataclass
class ScanJobPage:
code: Optional[str]
empty: bool
rotation: int
@dataclass
@@ -226,6 +238,7 @@ def handle_process_scans(the_job: TheJob):
site_id = job.in_json['site_id'] # type: ignore
task_ids = job.in_json['task_ids'] # type: ignore
in_files: List[str] = job.in_json['in_files'] # type: ignore
double_sided: bool = job.in_json.get('double_sided', False) # type: ignore
sess = db.get_session()
contest = sess.query(db.Contest).options(joinedload(db.Contest.round)).get(contest_id)
@@ -247,10 +260,26 @@ def handle_process_scans(the_job: TheJob):
for fi, fn in enumerate(in_files)]
results = pool.map(_process_scan_file, args)
def _parse_code(pr: ScanJobPage, sp: db.ScanPage) -> Optional[str]:
def _fill_continuation(sp: db.ScanPage, prev_page: db.ScanPage):
sp.user_id = prev_page.user_id
sp.task_id = prev_page.task_id
sp.seq_id = prev_page.seq_id + 1
def _parse_code(pi: int, pr: ScanJobPage, sp: db.ScanPage, pp: Optional[db.ScanPage]) -> Optional[str]:
if pr.code is None:
if pr.empty:
sp.state = db.ScanPageState.probably_empty
elif double_sided and pi % 2 == 1 and pp:
# Sudá stránka bez kódu při oboustranném skenování -> pokračování té předchozí
sp.state = db.ScanPageState.probably_ok
_fill_continuation(sp, pp)
else:
sp.state = db.ScanPageState.unknown
return None
# Máme kód a pokud ho nerozpoznáme, tak je sken oficiálně divný
sp.state = db.ScanPageState.ufo
fields = pr.code.split(':')
if fields[0] != 'MO':
return 'Neznámý prefix'
@@ -258,12 +287,17 @@ def handle_process_scans(the_job: TheJob):
if len(fields) == 2:
if fields[1] == '*':
# Univerzální hlavička úlohy
sp.seq_id = db.SCAN_PAGE_FIX
sp.state = db.ScanPageState.unknown
return None
if fields[1] == '+':
# Pokračovací papír s kódem
sp.seq_id = db.SCAN_PAGE_CONTINUE
if pp:
sp.state = db.ScanPageState.ok
_fill_continuation(sp, pp)
return None
else:
sp.state = db.ScanPageState.unknown
return "Pokračovací papír bez rozpoznaného předchozího skenu"
elif len(fields) == 4:
if not fields[3].isnumeric():
@@ -276,12 +310,13 @@ def handle_process_scans(the_job: TheJob):
return 'Neznámá úloha'
if user_id not in user_ids:
return 'Neznámý účastník'
sp.state = db.ScanPageState.ok
sp.user_id = user_id
sp.task_id = tasks_by_code[fields[2]].task_id
sp.seq_id = 1
return None
return 'Neznamý formát kódu'
return 'Neznámý formát kódu'
# Pokud jsme job spustili podruhé (ruční retry), chceme smazat všechny záznamy v scan_pages.
# Pozor, nesynchronizujeme ORM, ale nevadí to, protože v této chvíli mame čerstvou session.
@@ -300,23 +335,22 @@ def handle_process_scans(the_job: TheJob):
job_id=job.job_id,
file_nr=fi,
page_nr=pi,
seq_id=db.SCAN_PAGE_FIX,
seq_id=0,
rotation=pr.rotation,
)
err = _parse_code(pr, sp)
if sp.seq_id == 1:
prev_page = sp
elif sp.seq_id == db.SCAN_PAGE_CONTINUE and prev_page is not None:
sp.user_id = prev_page.user_id
sp.task_id = prev_page.task_id
sp.seq_id = prev_page.seq_id + 1
prev_page = sp
else:
prev_page = None
err = _parse_code(pi, pr, sp, prev_page)
if err is not None:
logger.debug(f'Scan: {fi}/{pi} ({pr.code}): {err}')
sp.seq_id = db.SCAN_PAGE_UFO
# Zapamatujeme si stránku pro navázání
if sp.state in (db.ScanPageState.ok, db.ScanPageState.probably_ok):
prev_page = sp
elif sp.state == db.ScanPageState.probably_empty:
pass # přeskočíme
else:
prev_page = None # na neznámé a divné stránky nejda navázat (ani navázat napříč jimi)
sess.add(sp)
num_pages += 1
@@ -355,27 +389,57 @@ def _process_scan_file(args: ScanJobArgs) -> ScanJobResult:
)
del page_img
full_img = full_img.convert('L') # Grayscale
full_size = full_img.size
(full_width, full_height) = full_img.size
test_img = full_img.convert('L').point(lambda x: 255 if x > SCAN_BW_THRESHOLD else 0, mode='1')
codes = pyzbar.decode(full_img, symbols=[pyzbar.ZBarSymbol.QRCODE])
codes: List[pyzbar.Decoded] = pyzbar.decode(test_img, symbols=[pyzbar.ZBarSymbol.QRCODE])
codes = [c for c in codes if c.type == 'QRCODE' and c.data.startswith(b'MO:')]
qr = None
rotation = 0
if codes:
if len(codes) > 1:
logger.warning(f'Scan: Strana #{page_nr} obsahuje více QR kódů')
code = codes[0]
qr = code.data.decode('US-ASCII')
# FIXME: Tady by se dala podle kódu otočit stránka
res.pages.append(ScanJobPage(code=qr))
if code.orientation == "RIGHT":
rotation = 1
elif code.orientation == "DOWN":
rotation = 2
elif code.orientation == "LEFT":
rotation = 3
# Pokud jsme našli QR kód, stránka určitě prázdná není, jinak to musíme odhadnout
empty = False
entropy = 0.0
if not qr:
# uříznutí okrajů (nepřesné okraje od skeneru)
c = EMPTY_PAGE_CROP
test_img = test_img.crop((c, c, full_width-c, full_height-c))
# konverze na černobílý obrázek
# test_img = test_img.convert('L').point(lambda x: 255 if x > EMPTY_PAGE_BW_THRESHOLD else 0, mode='1')
# eroze "smetí"
for _ in range(EMPTY_PAGE_ERODE_COUNT):
test_img = test_img.filter(ImageFilter.MaxFilter(EMPTY_PAGE_ERODE_SIZE))
# debug: uložení si zpracovaného obrázku
test_img.save(f'{args.out_prefix}-{page_nr:04d}-test-blank.png')
entropy = test_img.entropy()
if entropy < EMPTY_PAGE_ENTROPY_THRESHOLD:
empty = True
# Obrázek uložíme se stejnou orientací jako v PDFku kvůli jednoduchosti zpracování, ale otočení si zapíšeme pro frontend
res.pages.append(ScanJobPage(code=qr, empty=empty, rotation=rotation))
full_img.save(f'{args.out_prefix}-{page_nr:04d}-full.png')
small_img = full_img.resize((full_size[0] // 4, full_size[1] // 4))
small_img = full_img.resize((full_width // 4, full_height // 4))
small_img.save(f'{args.out_prefix}-{page_nr:04d}-small.png')
logger.debug(f'Scan: Strana #{page_nr}: {qr}')
logger.debug(f'Scan: Strana #{page_nr}: {qr} (entropy: {entropy:.10f}, empty: {empty})')
return res
@@ -505,9 +569,10 @@ def handle_sort_scans(the_job: TheJob):
if p.file_nr not in readers:
readers[p.file_nr] = PyPDF2.PdfFileReader(job.file_path(in_files[p.file_nr]), strict=False)
# Přihodíme správnou stránku na výstup
writer.add_page(
readers[p.file_nr].pages[p.page_nr]
)
pp = readers[p.file_nr].pages[p.page_nr]
if p.rotation > 0:
pp = pp.rotate(-90*p.rotation)
writer.add_page(pp)
# Zapíšeme vše do správného souboru
with open(job.file_path(paper.filename()), 'wb') as f:
writer.write(f)
Loading