Kindle for PC コレクション対応記念
嘘です。
もうそろそろなんとかしたかっただけです
本来なら元記事の変更で済ますべきなんでしょうが、ソースコードがかなり変更されたので、新記事にしました。
の強化版です
変更点
- 設定ファイルを使うようになった
カレントディレクトリのkindless.iniファイルを参照して動作設定をするようになりました。
コマンドラインで設定ファイルを指定すると、その設定ファイルを読むようになりました。
PyInstallerやNuitkaで実行ファイル化できるかもしれません - tkinter以外のダイアログを表示するようにしました
ソースコードを置き換えればWXPython , PyQt5 , PyQt6 , tkinterのうちの一つを選べます - ウィンドウ誤爆対策
実行ファイルがkindle.exeのものを選択するようにしました - ウィンドウからタイトルを拾うようにした
タイトルがウィンドウに表示されているので、それを初期値としてダイアログボックスに予め入力しておく事にしました(設定で切替可) - 最初のページに自動で移動
最初のページに自動的に移動するようにしました(設定で切替可) - エラーチェックの強化
スクショ中、Kindle for PCを閉じれば終了するようにしました。また、なるべくKindle for PCをフルスクリーンにしたまま終了しないようにしました - ソースコード整理
ちゃんとmain関数内に入れた - 後処理の強化
小説の場合、モノクロ判定をして、モノクロの場合はモノクロPNGで保存するようにしました
コミックの場合でもモノクロ判定とトリミングを行うようにしました - 一部の処理のスレッド化
コミックが対象の場合、スクショ後にまとめて画像変換とトリミングをしますが、そのときに必要な判別処理はスクショ中に別スレッドで行うようにしたため、速度が安定しました
ソースコード
ソースコードは4つに分かれています。
kindless.py
from dataclass import KindleSSConfig, read_config
from wxdialog import SimpleDialog, Icon
from WindowInfo import *
import threading, queue
import sys, os, os.path as osp, datetime , time
import shutil
import dataclasses
import pyautogui as pag
import cv2, numpy as np
from PIL import ImageGrab
rep_list = [[' ',' '],[':',':'],[';',';'],['(','('],[')',')'],['[','['],[']',']'],
['&','&'],['"','”'],['|','|'],['?','?'],['!','!'],['*','*'],['\\','¥'],
['<','<'],['>','>'],['/','/']]
@dataclasses.dataclass
class Margin:
top : int
bottom : int
left : int
right : int
@dataclasses.dataclass
class ThreadArgs:
endflag : bool
page : int
filename : str
image : np.ndarray
@dataclasses.dataclass
class ThreadResult:
margin_left : int
margin_right : int
gray : bool
filename : str
class CaptureWrapper:
def __init__(self):
pass
def capture(self) -> np.ndarray:
cap = ImageGrab.grab()
return cv2.cvtColor(np.array(cap), cv2.COLOR_RGB2BGR)
def imread(filename: str, flags=cv2.IMREAD_COLOR, dtype=np.uint8) -> np.ndarray:
n = np.fromfile(filename, dtype)
img = cv2.imdecode(n, flags)
return img
def imwrite(filename: str, img : np.ndarray, params=None) -> bool:
ext = os.path.splitext(filename)[1]
result, n = cv2.imencode(ext, img, params)
if result:
with open(filename, mode='w+b') as f:
n.tofile(f)
return True
else:
return False
def trim_check(img: np.ndarray, color, margin: Margin):
def cmps(img, xrange, yrange , color, xdef):
rt = xdef
for x in xrange:
if (img[yrange[0]:yrange[1] , x] != color).any():
rt = x
break
return rt
sx, sy = img.shape[1], img.shape[0]
nx, ny = margin.top, margin.bottom
xx, xy = sx - margin.left, sy - margin.right
lm = cmps(img, range(nx, xx), (ny, xy),color, sx)
if lm == nx:
lm = 0
rm = cmps(img, reversed(range(nx, xx)), (ny, xy), color, 0)
if rm == xx:
rm = sx
return lm,rm
def color_check(img: np.ndarray, mg:Margin) -> int:
imx = img.shape[1]
imy = img.shape[0]
img_blue, img_green, img_red = cv2.split(img[mg.top : imx - mg.bottom , mg.left : imy - mg.right])
img_bg = np.abs(img_blue.astype(int) - img_green.astype(int))
img_gr = np.abs(img_green.astype(int) - img_red.astype(int))
img_rb = np.abs(img_red.astype(int) - img_blue.astype(int))
return max(img_bg.max(),img_gr.max(),img_rb.max())
def capture(cfg: KindleSSConfig, dir_title: str, page: int):
print('Cap start')
sc_w, sc_h = pag.size()
cap = CaptureWrapper()
imp = cap.capture()
def cmps(img,rng):
for i in rng:
if np.all(img[20][i] != img[19][0]):
return i
lft = cmps(imp,range(cfg.left_margin, imp.shape[1] - cfg.right_margin))
rht = cmps(imp,reversed(range(cfg.left_margin, imp.shape[1] - cfg.right_margin)))
print(lft, sc_w - rht, imp[int(sc_h / 2) , lft +1])
comic = True if lft < 60 and sc_w - rht < 60 and max(imp[int(sc_h / 2) , lft +1]) == 0 else False
ext = '.png' if comic else cfg.file_extension
old = np.zeros((sc_h , rht-lft, 3), np.uint8)
if comic and cfg.trim_after_capture:
arg = queue.Queue()
rslt = queue.Queue()
cv = threading.Condition()
trim = Margin(cfg.trim_margin_top, cfg.trim_margin_bottom, cfg.trim_margin_left, cfg.trim_margin_right)
gray = Margin(cfg.grayscale_margin_top, cfg.grayscale_margin_bottom, cfg.grayscale_margin_left, cfg.grayscale_margin_right)
thr = threading.Thread(args=(cv, arg, trim, gray, rslt, cfg.grayscale_threshold),target=thread)
thr.start()
loop = True
while loop:
if GetWindowHandleWithName(cfg.window_title, cfg.execute_filename) == None:
sys.exit()
filename = osp.join(dir_title , str(page).zfill(3) + ext)
start = time.perf_counter()
while True:
time.sleep(cfg.capture_wait)
ss = cap.capture()
ss = ss[:, lft: rht]
if not np.array_equal(old, ss):
break
if time.perf_counter()- start > cfg.timeout_wait:
pag.press(cfg.fullscreen_key)
loop = False
break
if loop:
old = ss.copy()
if not comic and cfg.trim_after_capture:
if color_check(ss, Margin(0,0,0,0)) <= cfg.grayscale_threshold:
imwrite(filename,ss[:,:,1])
else:
imwrite(filename,ss)
else:
with cv:
arg.put(ThreadArgs( False, page, filename, ss ))
cv.notify()
print('Page:', page, ' ', ss.shape, time.perf_counter() - start, 'sec')
page += 1
pag.keyDown(cfg.nextpage_key)
if comic and cfg.trim_after_capture:
with cv:
arg.put(ThreadArgs(True, 0, '', None))
cv.notify()
thr.join()
r : list[ThreadResult] = []
while not rslt.empty():
r += [rslt.get()]
ml = min([x.margin_left for x in r])
mr = max([x.margin_right for x in r])
print('trim =', ml, mr)
for i in r:
print(i.filename, end='')
s = imread(i.filename)
s = s[:,ml:mr]
if i.gray:
s = cv2.cvtColor(s, cv2.COLOR_RGB2GRAY)
print(' is grayscale', end = '')
fn = osp.splitext(i.filename)[0] + cfg.file_extension
os.remove(i.filename)
imwrite(fn,s)
print()
return
def thread(cv: threading.Condition, que: queue.Queue, trm: Margin, gray: Margin, out: queue.Queue,gs : int):
end_flag = False
sc_w, sc_h = pag.size()
ml = sc_w
mr = 0
while not end_flag:
while not que.empty():
arg : ThreadArgs = que.get()
if arg.endflag:
end_flag = True
break
tm = trim_check(arg.image, arg.image[1, 1],trm)
ml = min(ml, tm[0])
mr = max(mr, tm[1])
gst = Margin(gray.top, gray.bottom, gray.left + ml, mr - gray.right)
gr = (color_check(arg.image, gst) <= gs)
imwrite(arg.filename, arg.image)
rslt = ThreadResult(tm[0], tm[1], gr, arg.filename)
out.put(rslt)
else:
with cv:
cv.wait()
def main():
if len(sys.argv) >= 2:
ini = sys.argv[1]
else:
ini = 'kindless.ini'
cfg : KindleSSConfig = read_config(KindleSSConfig(), ini)
ghwnd = GetWindowHandleWithName(cfg.window_title, cfg.execute_filename)
if ghwnd == None:
SimpleDialog.infomation(title="エラー", label="Kindleが見つかりません", icon=Icon.Exclamation)
sys.exit()
t = GetWindowText(ghwnd)
if (idx := t.find(' - ')) != -1:
t = t[idx + 3 :]
for i in rep_list:
t = t.replace(i[0],i[1])
else:
t = str(datetime.datetime.now().strftime("%Y_%m_%d_%H_%M_%S"))
if not cfg.auto_title:
t = ''
SetForeWindow(ghwnd)
time.sleep(cfg.short_wait)
if cfg.force_move_first_page:
pag.hotkey(*cfg.pagejump_key)
time.sleep(cfg.short_wait)
pag.press(cfg.pagejump)
pag.press('enter')
time.sleep(cfg.capture_wait)
pag.press(cfg.fullscreen_key)
time.sleep(cfg.long_wait)
sc_w, sc_h = pag.size()
pag.moveTo(sc_w / 2, sc_h / 2)
ok, book_title = SimpleDialog.askstring(title="タイトル入力", label="タイトルを入れてね",value= t, width=400)
if not ok:
pag.press(cfg.fullscreen_key)
time.sleep(cfg.long_wait)
sys.exit()
append = False
if book_title[0] == '+':
append = True
book_title = book_title[1:]
dir_title = osp.join(cfg.base_save_folder,book_title)
print(dir_title)
page = 1
if osp.exists(dir_title):
if append:
page = max([int(os.path.splitext(os.path.basename(i))[0])
for i in os.listdir(dir_title)]) + 1 if append else 1
elif cfg.overwrite:
shutil.rmtree(dir_title)
os.makedirs(dir_title)
else:
pag.press(cfg.fullscreen_key)
time.sleep(cfg.long_wait)
SimpleDialog.infomation(title="エラー", label="ディレクトリが存在します", icon=Icon.Exclamation)
sys.exit()
else:
try:
os.makedirs(dir_title)
except OSError as e:
pag.press(cfg.fullscreen_key)
time.sleep(cfg.long_wait)
SimpleDialog.infomation(title="エラー", label="ディレクトリが作成できませんでした", icon=Icon.Exclamation)
sys.exit()
time.sleep(cfg.fullscreen_wait)
capture(cfg, dir_title, page)
if __name__ == "__main__":
main()
WindowInfo.py
from ctypes import (windll, WINFUNCTYPE, POINTER, c_bool, c_int, c_ulong, pointer, create_unicode_buffer)
from ctypes.wintypes import HWND, RECT
import os.path
EnumWindows = windll.user32.EnumWindows
WNDENUMPROC = WINFUNCTYPE(c_bool, POINTER(c_int), POINTER(c_int))
ignores = ['Default IME', 'MSCTFIME UI']
ghwnd = None
wintitle = ''
pName = None
windowlist = []
# uflags
SWP_ASYNCWINDOWPOS = 0x4000
SWP_DEFERERASE = 0x2000
SWP_DRAWFRAME = 0x0020
SWP_FRAMECHANGED = 0x0020
SWP_HIDEWINDOW = 0x0080
SWP_NOACTIVATE = 0x0010
SWP_NOCOPYBITS = 0x0100
SWP_NOMOVE = 0x0002
SWP_NOOWNERZORDER = 0x0200
SWP_NOREDRAW = 0x0008
SWP_NOREPOSITION = 0x0200
SWP_NOSENDCHANGING = 0x0400
SWP_NOSIZE = 0x0001
SWP_NOZORDER = 0x0004
SWP_SHOWWINDOW = 0x0040
def EnumWindowsProc(hwnd, lParam):
global ghwnd, wintitle, pname
length = windll.user32.GetWindowTextLengthW(hwnd)
buff = create_unicode_buffer(length + 1)
windll.user32.GetWindowTextW(hwnd, buff, length + 1)
if buff.value.find(wintitle) != -1:
if pname is not None:
p = os.path.basename(GetWindowThreadProcessName(hwnd))
if p.upper() != pname.upper():
return True
ghwnd = hwnd
return False
return True
def EnumWindowsListProc(hwnd, lParam):
global windowlist, ignores
length = windll.user32.GetWindowTextLengthW(hwnd)
buff = create_unicode_buffer(length + 1)
windll.user32.GetWindowTextW(hwnd, buff, length + 1)
if not any([buff.value.find(x) != -1 for x in ignores]) and buff.value != '':
windowlist.append({'Text': buff.value, 'HWND': hwnd, 'Pos': GetWindowRect(ghwnd), 'pid': GetWindowThreadProcessId(hwnd) , 'Location': GetWindowThreadProcessName(hwnd)})
return True
def GetWindowThreadProcessId(hwnd):
pid = c_ulong()
windll.user32.GetWindowThreadProcessId(hwnd,pointer(pid))
return pid.value
def GetWindowThreadProcessName(hwnd):
pid = c_ulong()
windll.user32.GetWindowThreadProcessId(hwnd, pointer(pid))
handle = windll.kernel32.OpenProcess(0x0410, 0, pid)
buffer_len = c_ulong(1024)
buffer = create_unicode_buffer(buffer_len.value)
windll.kernel32.QueryFullProcessImageNameW(handle, 0,pointer(buffer),pointer(buffer_len))
buffer = buffer[:]
buffer = buffer[:buffer.index("\0")]
return str(buffer)
def GetWindowHandle(title):
global ghwnd, wintitle, pname
ghwnd = None
wintitle = title
pname = None
EnumWindows(WNDENUMPROC(EnumWindowsProc), 0)
return ghwnd
def GetWindowHandleWithName(title,name):
global ghwnd, wintitle, pname
ghwnd = None
wintitle = title
pname = name
EnumWindows(WNDENUMPROC(EnumWindowsProc), 0)
return ghwnd
def GetWindowList():
global windowlist
windowlist = []
EnumWindows(WNDENUMPROC(EnumWindowsListProc), 0)
return windowlist
def SetForeWindow(hwnd):
windll.user32.SetForegroundWindow(hwnd)
def SetWindowPos(hwnd,x,y,cx,cy,uflags):
hwndinsertafter = HWND()
windll.user32.SetWindowPos(hwnd,hwndinsertafter,x,y,cx,cy,uflags)
def GetWindowText(hwnd):
length = windll.user32.GetWindowTextLengthW(hwnd)
buff = create_unicode_buffer(length + 1)
windll.user32.GetWindowTextW(hwnd, buff, length + 1)
return buff.value
def GetWindowRect(hwnd):
rect = RECT()
windll.user32.GetWindowRect(hwnd, pointer(rect))
return rect.left,rect.top,rect.right,rect.bottom
dataclass.py
from typing import TypeVar
import dataclasses
import configparser
import os.path as osp
homedir = osp.join(osp.expanduser('~'),'kindle_scan')
@dataclasses.dataclass
class KindleSSConfig:
window_title : str = 'Kindle'
execute_filename : str = 'KINDLE.EXE'
nextpage_key : str = 'left'
fullscreen_key : str = 'f11'
pagejump_key : list = dataclasses.field(default_factory=lambda: ['ctrl','g'])
pagejump : str = '1'
short_wait : float = 0.1
long_wait : float = 0.2
capture_wait : float = 0.35
timeout_wait : float = 5.0
fullscreen_wait : float = 2.0
left_margin : int = 0
right_margin : int = 0
base_save_folder : str = homedir
overwrite : bool = True
trim_after_capture : bool = False
force_move_first_page : bool = True
auto_title : bool = True
file_extension : str = '.png'
grayscale_threshold : int = 2
grayscale_margin_top : int = 1
grayscale_margin_bottom : int = 16
grayscale_margin_left : int = 1
grayscale_margin_right : int = 1
trim_margin_top : int = 1
trim_margin_bottom : int = 16
trim_margin_left : int = 1
trim_margin_right : int = 1
def key_combination(strk : str)->list:
lst = strk.split('+')
return [i.strip() for i in lst]
def file_extension(ext : str)->str:
return ext if ext[0] == '.' else '.' + ext
special_function = { 'pagejump_key' : key_combination , 'file_extension' : file_extension }
default_ini_name = 'kindless.ini'
default_section = 'KINDLESS'
DataClass = TypeVar('DataClass', bound= KindleSSConfig)
#
#
def read_config(dc: DataClass, ini: str)-> DataClass:
if not osp.exists(ini):
raise FileNotFoundError('ini file not found')
section_name = default_section
config = configparser.ConfigParser()
config.read(ini, encoding= 'utf-8')
for k, attr in dc.__annotations__.items():
if k in special_function.keys():
try:
setattr(dc, k, special_function[k]( config.get( section_name, k )))
except configparser.NoOptionError as e:
print('WARNING : {} NO OPTION'.format(k))
pass
else:
try:
if attr is int:
setattr(dc, k, config.getint( section_name, k))
elif attr is float:
setattr(dc, k, config.getfloat( section_name, k))
elif attr is bool:
setattr(dc, k, config.getboolean( section_name, k))
elif attr is str:
setattr(dc, k, config.get( section_name, k))
except configparser.NoOptionError as e:
print('WARNING : {} NO OPTION'.format(k))
return dc
wxdialog.py
import wx
from enum import Enum
app = wx.App()
class Icon(Enum):
Information = wx.ICON_INFORMATION
Question = wx.ICON_QUESTION
Exclamation = wx.ICON_EXCLAMATION
Warning = wx.ICON_EXCLAMATION
Error = wx.ICON_ERROR
class SimpleDialog:
@staticmethod
def askstring(title="", label="",value= "" ,parent=None, width=0, height=0)-> tuple[bool, str]:
dlg = wx.TextEntryDialog(None, label,caption = title,value = value)
dlg.Size = (width, height)
dlg.WindowStyle |= wx.STAY_ON_TOP
r = dlg.ShowModal() == wx.ID_OK
v = dlg.GetValue()
dlg.Destroy()
return r, v
@staticmethod
def ask(parent = None, title="", label="", icon=Icon.Information)-> bool:
dlg = wx.MessageDialog(None, label, title, wx.OK | wx.CANCEL | icon.value)
dlg.WindowStyle |= wx.STAY_ON_TOP
r = dlg.ShowModal() == wx.ID_OK
dlg.Destroy()
return r
@staticmethod
def infomation(parent = None, title="", label="", icon=Icon.Information)-> bool:
dlg = wx.MessageDialog(None, label, title, wx.OK | icon.value)
dlg.WindowStyle |= wx.STAY_ON_TOP
dlg.ShowModal()
dlg.Destroy()
return True
設定ファイル
kindless.ini
[KINDLESS]
window_title = Kindle
execute_filename = kindle.exe
# kindle_window_title Kindleの実行ファイル名
# kindle_execute_filename Kindle for PCの実行ファイル名
nextpage_key = left
fullscreen_key = f11
pagejump_key = ctrl + g
pagejump = 1
# kindle_nextpage_key 次のページを表示するキー
# kindle_fullscreen_key フルスクリーンにするキー
# kindle_pagejump_key ページ移動をするキー
# kindle_pagejump 指定のページ
short_wait = 0.1
long_wait = 0.2
capture_wait = 0.35
timeout_wait = 5.0
fullscreen_wait = 3.0
# short_wait 基本の短いウェイト
# long_wait 基本の長いウェイト
# capture_wait 次ページキーを押してからキャプチャするまでのウェイト
# timeout_wait 次ページキーを押しても同じ画像だった時に再試行を試みる秒数(最終ページ判別)
# fullscreen_wait キャプチャ開始前のウエイト
left_margin = 1
right_margin = 1
#サイズ自動設定のときの左右側マージン
#(常駐ソフトなどで、左右に何かがあるときに使用)
base_save_folder = e:\kindle_ss\
# 保存する場所 タイトルの前に入れられる
overwrite = True
trim_after_capture = True
force_move_first_page = True
auto_title = True
# overwrite 既に同じフォルダがあったときに、上書きする
# trim_after_capture キャプチャが終了した後に、コミックならコミック用のトリミングをする
# force_move_first_page キャプチャ前に、自動的に1ページ目にジャンプする
# ウィンドウの情報からタイトルを自動で入力する
file_extension = png
# file_extension 保存する画像のフォーマット
grayscale_margin_top = 0
grayscale_margin_bottom = 0
grayscale_margin_left = 0
grayscale_margin_right = 0
# グレイスケールの判別をするときの範囲
trim_margin_top = 1
trim_margin_bottom = 16
trim_margin_left = 0
trim_margin_right = 0
# コミックのトリミングをするときの範囲
grayscale_threshold = 7
# grayscale_threshold グレイスケール変換をするときのRGBの差の許容値
# ※グレイスケールのものをカラーjpegで保存すると、RGB値に誤差が出る事があります。その許容値です
wxpythonを使っていますが、
この記事のいずれか1つのファイルと置き換える事で、tkinter, PyQt5, PyQt6と変更出来ます
Windows機種依存ですが、Macでも動作するPyWinCtlなるものがあるようですから、そのあたりを書き換えればMacでも動作するかもしれません
ソースコードが4つに分かれているのは、長くなるから以外の理由は無いので、1つにまとめても問題ありません。まとめたのはgistにでも置いておきます
あ、多分Python3.9以降で動作します。開発時環境は3.10.11と3.11.4です
著作権について
このスクリプトを使用して作成した画像ファイルは、私的複製として、個人的に使用する用途以外では使用できません。
重要な事なので何度でも書きます
機能改善(08/26追加)
使ってみて、ちょっと気になったところを修正
自動入力されているタイトルにWindows禁止文字が含まれると終了してしまうので、あと、全角スペースをファイル名に入れるのが嫌いなので、予め変換して自動入力にするようにした。
極端に長い画像の書籍(500ページ以上?)だと、途中で止まってしまう事があります。毎回同じ所で止まるので仕様でしょう。
なので、追記モードを実装しました。
タイトル入力時にタイトルの頭に半角の+
を入力すると、追記モードになって続きから始めます。現在表示しているところからなので、途中で止まったときは1ページ進めてから実行してください。
なお、修正はkindless.pyのみです。Gistに置いた1ファイルにまとめたものは修正していません
おまけ
撮ったものは圧縮ファイルとかにまとめると思いますが、圧縮自体をこのスクリプトに含ませる予定はありません。下のようなもので対応してください。zip.exeがパスに通っている必要があります
@echo off
for /d %%a in (*.*) do (
echo ------------------------------------------------------------------ %%a
cd "%%a"
zip -m -T -r -o -9 "%%a.zip" *
move "%%a.zip" ..
cd ..
rmdir "%%a"
)