def main(): try: opts, args = getopt.getopt(sys.argv[1:], "t:o:s:h", ["text=", "output=", "script=", "help"]) except getopt.GetoptError as err: # print help information and exit: print(err) # will print something like "option -a not recognized" usage() sys.exit(2) output = None text = None script = None for o, a in opts: if o in ("-t", "--text"): text = a elif o in ("-o", "--output"): output = a elif o in ("-s", "--script"): script = a elif o in ("-h", "--help"): usage() sys.exit() else: assert False, "unhandled option" img = scriptList[script](text, method=Image.LANCZOS) metadata = PngInfo() metadata.add_itxt("Content", text) img.save(output, pnginfo=metadata)
def add_png_metadata(self, path, glyph): ''' given the path to a png file and the glyph it contains, write appropriate metadata fields into the file ''' self.metadata['Description'] = self.metadata[ '_Description_tmpl'].format(glyph=glyph) self.metadata['Title'] = self.metadata['_Title_tmpl'].format( glyph=glyph, border=self.args['bordercolor_name'], color=self.args['bgcolor_name']) with Image.open(path) as image: info = PngInfo() for entry in ["Author", "Description", "Title", "Software"]: if not entry.startswith('_'): info.add_itxt(entry, self.metadata[entry], "en", entry) basename, filename = os.path.split(path) newname = os.path.join(basename, 'new-' + filename) image.save(newname, pnginfo=info) if self.args['verbose']: with Image.open(newname) as image: print("new image is", newname) print(image.info) os.unlink(path) os.rename(newname, path)
class Image: def __init__(self, image: ndarray, from_color_space: str = None, to_color_space: str = None): raiseif( not isinstance(image, ndarray), UnearthtimeException(f'Image is type [{type(image)}] and must be type `numpy.ndarray`') ) self.__image, self.__color_space = Image.__resolve_image(image, from_color_space, to_color_space) self.__hash = 0 self.__height, self.__width = image.shape[0], image.shape[1] self.__info = PngInfo() def __copy__(self): return Image(self.__image.copy(), self.__color_space, self.__color_space) def __getattr__(self, attr): if hasattr(self.__image, attr): return getattr(self.__image, attr) def __hash__(self): if not self.__hash: self.__hash = hash((self.__image.data.toreadonly, self.__image.sum(), self.__color_space, self.__width, self.__height)) return self.__hash def __abs__(self): return Image(self.__image.__abs__(), self.__color_space) def __add__(self, value, /): return Image(self.__image.__add__(value), self.__color_space) def __and__(self, value, /): return Image(self.__image.__and__(value), self.__color_space) def __bool__(self): return self.__image.__bool__() def __complex__(self): return self.__image.__complex__() def __contains__(self, value, /): return self.__image.__contains__(value) def __delitem__(self, value, /): self.__image.__delitem__(value) def __divmod__(self, value, /): d, m = self.__image.__divmod__(value) return Image(d, self.__color_space), Image(m, self.__color_space) def __eq__(self, value, /): return self.__image.__eq__(value) def __float__(self): return self.__image.__float__() def __floordiv__(self, value, /): return Image(self.__image.__floordiv__(value), self.__color_space) def __getitem__(self, value, /): return self.__image.__getitem__(value) def __ge__(self, value, /): return self.__image.__ge__(value) def __gt__(self, value, /): return self.__image.__gt__(value) def __iadd__(self, value, /): self.__image.__iadd__(value) return self def __iand__(self, value, /): self.__image.__iand__(value) return self def __ifloordiv__(self, value, /): self.__image.__ifloordiv__(value) return self def __ilshift__(self, value, /): self.__image.__ilshift__(value) return self def __imatmul__(self, value, /): self.__image.__imatmul__(value) return self def __imod__(self, value, /): self.__image.__imod__(value) return self def __imul__(self, value, /): self.__image.__imul__(value) return self def __index__(self): return self.__image.__index__() def __int__(self): return self.__image.__int__() def __invert__(self): return Image(self.__image.__invert__(), self.__color_space) def __ior__(self, value, /): self.__image.__ior__(value) return self def __ipow__(self, value, /): self.__image.__ipow__(value) return self def __irshift__(self, value, /): self.__image.__irshift__(value) return self def __isub__(self, value, /): self.__image.__isub__(value) return self def __iter__(self): return self.__image.__iter__() def __itruediv__(self, value, /): self.__image.__itruediv__(value) return self def __ixor__(self, value, /): self.__image.__ixor__(value) return self def __len__(self): return self.__image.__len__() def __le__(self, value, /): return self.__image.__le__(value) def __lshift__(self, value, /): return Image(self.__image.__lshift__(value), self.__color_space) def __lt__(self, value, /): return self.__image.__lt__(value) def __matmul__(self, value, /): return Image(self.__image.__matmul__(value), self.__color_space) def __mod__(self, value, /): return Image(self.__image.__mod__(value), self.__color_space) def __mul__(self, value, /): return Image(self.__image.__mul__(value), self.__color_space) def __neg__(self): return Image(self.__image.__neg__(), self.__color_space) def __ne__(self, value, /): return self.__image.__ne__(value) def __or__(self, value, /): return Image(self.__image.__or__(value), self.__color_space) def __pos__(self): return Image(self.__image.__pos__(), self.__color_space) def __pow__(self, value, /): return Image(self.__image.__pow__(value), self.__color_space) def __radd__(self, value, /): return Image(self.__image.__radd__(value), self.__color_space) def __rand__(self, value, /): return Image(self.__image.__rand__(value), self.__color_space) def __rdivmod__(self, value, /): d, m = self.__image.__rdivmod__(self, value) return Image(d, self.__color_space), Image(m, self.__color_space) def __rfloordiv__(self, value, /): return Image(self.__image.__rfloordiv__(value), self.__color_space) def __rlshift__(self, value, /): return Image(self.__image.__rlshift__(value), self.__color_space) def __rmatmul__(self, value, /): return Image(self.__image.__rmatmul__(value), self.__color_space) def __rmod__(self, value, /): return Image(self.__image.__rmod__(value), self.__color_space) def __rmul__(self, value, /): return Image(self.__image.__rmul__(value), self.__color_space) def __ror__(self, value, /): return Image(self.__image.__ror__(value), self.__color_space) def __rpow__(self, value, /): return Image(self.__image.__rpow__(value), self.__color_space) def __rrshift__(self, value, /): return Image(self.__image.__rrshift__(value), self.__color_space) def __rshift__(self, value, /): return Image(self.__image.__rshift__(value), self.__color_space) def __rsub__(self, value, /): return Image(self.__image.__rsub__(value), self.__color_space) def __rtruediv__(self, value, /): return Image(self.__image.__rtruediv__(value), self.__color_space) def __rxor__(self, value, /): return Image(self.__image.__rxor__(value), self.__color_space) def __setitem__(self, value, /): self.__image.__setitem__(value) self.__hash = 0 def __sizeof__(self): return self.__image.__sizeof__() def __sub__(self, value, /): return Image(self.__image.__sub__(value), self.__color_space) def __truediv__(self, value, /): return Image(self.__image.__truediv__(value), self.__color_space) def __xor__(self, value, /): return Image(self.__image.__xor__(value), self.__color_space) @classmethod def from_base64(cls, base64: str, to_color_space: str = 'RGBA'): return cls(array(PILImage.open(BytesIO(a2b_base64(base64)))), 'RGBA', to_color_space) @classmethod def from_bytes(cls, bytes_: bytes, to_color_space: str = 'RGBA'): return cls(array(PILImage.open(BytesIO(bytes_))), 'RGBA', to_color_space) @classmethod def from_image(cls, img: PILImage, to_color_space: str = None): im = cls(array(img), img.mode, to_color_space) if isinstance(img, PngImageFile): if img.text: for key, value in img.text.items(): im.add_text(key, value) return im @classmethod def read_file(cls, fp: str, flags=None, to_color_space: str = 'BGR'): return cls(cv.imread(fp, flags), 'BGR', to_color_space=to_color_space) @classmethod def read_url(cls, url: str, from_color_space: str = 'RGB', to_color_space: str = 'RGB', plugin=None, **plugin_args): tcs = to_color_space.upper() img = skio.imread(url, as_gray=tcs in ('GRAY', 'GREY'), plugin=plugin, **plugin_args) if tcs in ('GRAY', 'GREY'): return cls(img, 'GRAY', 'GRAY') else: return cls(img, from_color_space, to_color_space) @property def array(self): return self.__image @property def color_space(self): return self.__color_space @property def height(self): return self.__height @property def png_info(self): return self.__info @property def width(self): return self.__width def add_text(self, key, value, zip_: bool = False): self.__info.add_text(key, value, zip_) def add_itxt(self, key, value, lang: str = "", tkey: str = "", zip_: bool = False): self.__info.add_itxt(key, value, lang, tkey, zip_) def as_image(self): return PILImage.fromarray(self.__image) def change_color_space(self, color_space: str): self.__image, self.__color_space = Image.__resolve_image(self.__image, self.__color_space, color_space) self.__hash = 0 def compare_full(self, img: Image, rect_color: RGBColor = (0, 0, 255), line_thickness=1, line_type=cv.LINE_8): cim1, cim2 = self.with_color_space('BGR'), img.with_color_space('BGR') gim1, gim2 = cim1.with_color_space('GRAY'), cim2.with_color_space('GRAY') score, diff = gim1.compare_ssim(gim2, full=True) diff = (diff * 255).astype("uint8") threshold = cv.threshold(diff, 0, 255, cv.THRESH_BINARY_INV | cv.THRESH_OTSU)[1] contours = im.grab_contours(cv.findContours(threshold.copy(), cv.RETR_EXTERNAL, cv.CHAIN_APPROX_SIMPLE)) for contour in contours: x, y, w, h = cv.boundingRect(contour) cv.rectangle(cim1.array, (x, y), (x + w, y + h), rect_color, line_thickness, line_type) cv.rectangle(cim2.array, (x, y), (x + w, y + h), rect_color, line_thickness, line_type) mse_ = mse(gim1.array, gim2.array) return { "mse": mse_, "ssim": score, "image1": cim1, "image2": cim2, "diff": diff, "threshold": threshold } def compare_mse(self, img: Image): return mse(self.with_color_space('GRAY').array, img.with_color_space('GRAY').array) def compare_ssim(self, img: Image, **kwargs): return ssim(self.with_color_space('GRAY').array, img.with_color_space('GRAY').array, **kwargs) def copy(self): return self.__copy__() def draw_rectangle(self, pt1, pt2, color: RGBColor = (0, 0, 255), line_thickness: int = 1, line_type: int = cv.LINE_8): cv.rectangle(self.__image, pt1, pt2, color, line_thickness, line_type) def save(self, fp: str, format_=None, pnginfo=None, **params): if (format_ and format_.lower() == 'png') or (fp and fp.endswith('.png')): info = self.__info if pnginfo: if isinstance(pnginfo, PngInfo): overriding('stored png info.') info = pnginfo else: if not isinstance(pnginfo, dict): pnginfo = dict(pnginfo) for name, value in pnginfo.items(): info.add_text(name, str(value)) self.as_image().save(fp, format_, pnginfo=info, **params) else: self.as_image().save(fp, format_, **params) def show(self, name: str): cv.imshow(name, self.__image) def with_color_space(self, color_space: str): return Image(self.__image.copy(), self.__color_space, color_space) @staticmethod def __resolve_image(image: ndarray, from_color_space: str, to_color_space: str): if from_color_space is not None: fcs = from_color_space.upper() else: fcs = 'GRAY' if image.ndim == 2 else 'RGB' if image.ndim == 3 else 'RGBA' if to_color_space is not None: tcs = to_color_space.upper() else: tcs = fcs raiseif( len(set([cs for cs in dir(cv) if f'COLOR_{fcs}' in cs])) == 0, UnearthtimeException(f':[{fcs}2{tcs}]: Invalid color space conversion.') ) if fcs == 'GREY': fcs = 'GRAY' if tcs == 'GREY': tcs = 'GRAY' if fcs != tcs: cvtcs = f'COLOR_{fcs}2{tcs}' try: code = getattr(cv, cvtcs) except AttributeError: raise UnearthtimeException(f':[{fcs}2{tcs}]: Invalid color space conversion.') image = cv.cvtColor(image, code) return image, tcs
class MainWindow(QMainWindow): """Main application window""" def __init__(self) -> None: QMainWindow.__init__(self) self.setSizePolicy( QSizePolicy(QSizePolicy.Maximum, QSizePolicy.Maximum)) self.setMaximumSize(QSize(1920, 1080)) self.setStyleSheet("padding: 0px; margin: 0px;") self.setIconSize(QSize(32, 32)) self.setWindowTitle("BossyBot 2000 - Image Tagger") self.setWindowIcon(self.load_icon(icon)) self.menubar = QMenuBar(self) self.menubar.setSizePolicy(EXP_MAX) self.menubar.setMaximumSize(QSize(INFINITE, 30)) self.menu_file = QMenu('File', self.menubar) self.menu_options = QMenu('Options', self.menubar) self.menu_help = QMenu('Help', self.menubar) self.menubar.addAction(self.menu_file.menuAction()) self.menubar.addAction(self.menu_options.menuAction()) self.menubar.addAction(self.menu_help.menuAction()) self.open = QAction('Open', self) self.menu_file.addAction(self.open) self.open.triggered.connect(self.open_file) self.exit_button = QAction('Exit', self) self.exit_button.triggered.connect(lambda: sys.exit(0), Qt.QueuedConnection) self.menu_file.addAction(self.exit_button) self.setMenuBar(self.menubar) self.previous_button = QAction(self.load_icon(previous), '<<', self) self.next_button = QAction(self.load_icon(next_icon), '>>', self) self.rotate_left_button = QAction(self.load_icon(left), '', self) self.rotate_right_button = QAction(self.load_icon(right), '', self) self.play_button = QAction(self.load_icon(play), '', self) self.play_button.setCheckable(True) self.delete_button = QAction(self.load_icon(delete), '', self) self.reload_button = QAction(self.load_icon(reload), '', self) self.mirror_button = QAction('Mirror', self) self.actual_size_button = QAction('Actual Size', self) self.browser_button = QAction('Browser', self) self.browser_button.setCheckable(True) self.browser_button.setChecked(True) self.crop_button = QAction('Crop', self) self.crop_button.setCheckable(True) self.toolbuttons = { self.rotate_left_button: { 'shortcut': ',', 'connect': lambda: self.pixmap.setRotation(self.pixmap.rotation() - 90) }, self.rotate_right_button: { 'shortcut': '.', 'connect': lambda: self.pixmap.setRotation(self.pixmap.rotation() + 90) }, self.delete_button: { 'shortcut': 'Del', 'connect': self.delete }, self.previous_button: { 'shortcut': 'Left', 'connect': self.previous }, self.play_button: { 'shortcut': 'Space', 'connect': self.play }, self.next_button: { 'shortcut': 'Right', 'connect': self.next }, self.reload_button: { 'shortcut': 'F5', 'connect': self.reload } } self.toolbar = QToolBar(self) self.toolbar.setSizePolicy(EXP_MAX) self.toolbar.setMaximumSize(QSize(INFINITE, 27)) for _ in (self.browser_button, self.crop_button, self.mirror_button, self.actual_size_button): self.toolbar.addAction(_) self.addToolBar(Qt.TopToolBarArea, self.toolbar) for button in self.toolbuttons: button.setShortcut(self.toolbuttons[button]['shortcut']) button.triggered.connect(self.toolbuttons[button]['connect']) self.toolbar.addAction(button) self.centralwidget = QWidget(self) self.centralwidget.setSizePolicy(EXP_EXP) self.setCentralWidget(self.centralwidget) self.grid = QGridLayout(self.centralwidget) self.media = QGraphicsScene(self) self.media.setItemIndexMethod(QGraphicsScene.NoIndex) self.media.setBackgroundBrush(QBrush(Qt.black)) self.view = MyView(self.media, self) self.view.setSizePolicy(EXP_EXP) self.media.setSceneRect(0, 0, self.view.width(), self.view.height()) self.grid.addWidget(self.view, 0, 0, 1, 1) self.frame = QFrame(self.centralwidget) self.frame.setSizePolicy( QSizePolicy(QSizePolicy.Fixed, QSizePolicy.MinimumExpanding)) self.frame.setMinimumSize(QSize(325, 500)) self.frame.setStyleSheet( "QFrame { border: 4px inset #222; border-radius: 10; }") self.layout_widget = QWidget(self.frame) self.layout_widget.setGeometry(QRect(0, 400, 321, 91)) self.layout_widget.setContentsMargins(15, 15, 15, 15) self.grid2 = QGridLayout(self.layout_widget) self.grid2.setContentsMargins(0, 0, 0, 0) self.save_button = QPushButton('Yes (Save)', self.layout_widget) self.save_button.setSizePolicy(FIX_FIX) self.save_button.setMaximumSize(QSize(120, 26)) self.save_button.setVisible(False) self.grid2.addWidget(self.save_button, 1, 0, 1, 1) self.no_save_button = QPushButton('No (Reload)', self.layout_widget) self.no_save_button.setSizePolicy(FIX_FIX) self.no_save_button.setMaximumSize(QSize(120, 26)) self.no_save_button.setVisible(False) self.grid2.addWidget(self.no_save_button, 1, 1, 1, 1) self.label = QLabel("Current image modified, save it?", self.layout_widget) self.label.setSizePolicy(FIX_FIX) self.label.setMaximumSize(QSize(325, 60)) self.label.setVisible(False) self.label.setAlignment(Qt.AlignCenter) self.grid2.addWidget(self.label, 0, 0, 1, 2) self.layout_widget = QWidget(self.frame) self.layout_widget.setGeometry(QRect(0, 0, 321, 213)) self.ass = QRadioButton('Ass', self.layout_widget) self.ass_exposed = QRadioButton('Ass (exposed)', self.layout_widget) self.ass_reset = QRadioButton(self.frame) self.ass_group = QButtonGroup(self) self.breasts = QRadioButton('Breasts', self.layout_widget) self.breasts_exposed = QRadioButton('Breasts (exposed)', self.layout_widget) self.breasts_reset = QRadioButton(self.frame) self.breasts_group = QButtonGroup(self) self.pussy = QRadioButton('Pussy', self.layout_widget) self.pussy_exposed = QRadioButton('Pussy (exposed)', self.layout_widget) self.pussy_reset = QRadioButton(self.frame) self.pussy_group = QButtonGroup(self) self.fully_clothed = QRadioButton('Fully Clothed', self.layout_widget) self.fully_nude = QRadioButton('Fully Nude', self.layout_widget) self.nudity_reset = QRadioButton(self.frame) self.nudity = QButtonGroup(self) self.smiling = QRadioButton('Smiling', self.layout_widget) self.glaring = QRadioButton('Glaring', self.layout_widget) self.expression_reset = QRadioButton(self.frame) self.expression = QButtonGroup(self) self.grid3 = QGridLayout(self.layout_widget) self.grid3.setVerticalSpacing(15) self.grid3.setContentsMargins(0, 15, 0, 0) self.radios = { self.ass: { 'this': 'ass', 'that': 'ass_exposed', 'group': self.ass_group, 'reset': self.ass_reset, 'grid': (0, 0, 1, 1) }, self.ass_exposed: { 'this': 'ass_exposed', 'that': 'ass', 'group': self.ass_group, 'reset': self.ass_reset, 'grid': (0, 1, 1, 1) }, self.breasts: { 'this': 'breasts', 'that': 'breasts_exposed', 'group': self.breasts_group, 'reset': self.breasts_reset, 'grid': (1, 0, 1, 1) }, self.breasts_exposed: { 'this': 'breasts_exposed', 'that': 'breasts', 'group': self.breasts_group, 'reset': self.breasts_reset, 'grid': (1, 1, 1, 1) }, self.pussy: { 'this': 'pussy', 'that': 'pussy_exposed', 'group': self.pussy_group, 'reset': self.pussy_reset, 'grid': (2, 0, 1, 1) }, self.pussy_exposed: { 'this': 'pussy_exposed', 'that': 'pussy', 'group': self.pussy_group, 'reset': self.pussy_reset, 'grid': (2, 1, 1, 1) }, self.fully_clothed: { 'this': 'fully_clothed', 'that': 'fully_nude', 'group': self.nudity, 'reset': self.nudity_reset, 'grid': (3, 0, 1, 1) }, self.fully_nude: { 'this': 'fully_nude', 'that': 'fully_clothed', 'group': self.nudity, 'reset': self.nudity_reset, 'grid': (3, 1, 1, 1) }, self.smiling: { 'this': 'smiling', 'that': 'glaring', 'group': self.expression, 'reset': self.expression_reset, 'grid': (4, 0, 1, 1) }, self.glaring: { 'this': 'glaring', 'that': 'smiling', 'group': self.expression, 'reset': self.expression_reset, 'grid': (4, 1, 1, 1) }, } for radio in self.radios: radio.setSizePolicy(FIX_FIX) radio.setMaximumSize(QSize(150, 22)) self.radios[radio]['reset'].setGeometry(QRect(0, 0, 0, 0)) self.grid3.addWidget(radio, *self.radios[radio]['grid']) if self.radios[radio]['group'] != self.nudity: radio.toggled.connect( lambda x=_, y=radio: self.annotate(self.radios[y]['this'])) self.radios[radio]['group'].addButton(radio) self.radios[radio]['group'].addButton(self.radios[radio]['reset']) self.save_tags_button = QPushButton('Save Tags', self.layout_widget) self.save_tags_button.setSizePolicy(FIX_FIX) self.save_tags_button.setMaximumSize(QSize(120, 26)) self.grid3.addWidget(self.save_tags_button, 5, 1, 1, 1) self.grid.addWidget(self.frame, 0, 1, 1, 1) self.browse_bar = QLabel(self.centralwidget) self.browse_bar.setSizePolicy(EXP_FIX) self.browse_bar.setMinimumSize(QSize(0, 100)) self.browse_bar.setMaximumSize(QSize(INFINITE, 100)) self.browse_bar.setStyleSheet("background: #000;") self.browse_bar.setAlignment(Qt.AlignCenter) self.h_box2 = QHBoxLayout(self.browse_bar) self.h_box2.setContentsMargins(4, 0, 0, 0) self.grid.addWidget(self.browse_bar, 1, 0, 1, 2) hiders = [ self.no_save_button.clicked, self.save_button.clicked, self.reload_button.triggered ] for hider in hiders: hider.connect(self.save_button.hide) hider.connect(self.no_save_button.hide) hider.connect(self.label.hide) showers = [ self.mirror_button.triggered, self.rotate_right_button.triggered, self.rotate_left_button.triggered ] for shower in showers: shower.connect(self.save_button.show) shower.connect(self.no_save_button.show) shower.connect(self.label.show) self.no_save_button.clicked.connect(self.reload) self.browser_button.toggled.connect(self.browse_bar.setVisible) self.play_button.toggled.connect(lambda: self.frame.setVisible( (True, False)[self.frame.isVisible()])) self.reload_button.triggered.connect(self.reload) self.mirror_button.triggered.connect(lambda: self.pixmap.setScale(-1)) self.save_button.clicked.connect(self.save_image) self.play_button.toggled.connect( lambda: self.browser_button.setChecked( (True, False)[self.browse_bar.isVisible()])) self.crop_button.toggled.connect(self.view.reset) self.actual_size_button.triggered.connect(self.actual_size) self.browser_button.triggered.connect(self.browser) self.save_tags_button.clicked.connect(self.save_tags) self.view.got_rect.connect(self.set_rect) self.crop_rect = QRect(QPoint(0, 0), QSize(0, 0)) self.dir_now = os.getcwd() self.files = [] self.index = 0 self.refresh_files() self.pixmap_is_scaled = False self.pixmap = QGraphicsPixmapItem() self.active_tag = '' self.reset_browser = False self.txt = PngInfo() def set_rect(self, rect: tuple[QPointF, QPointF]): """Converts the crop rectangle to a QRect after a crop action""" self.crop_rect = QRect(rect[0].toPoint(), rect[1].toPoint()) def keyPressEvent(self, event: QKeyEvent): # pylint: disable=invalid-name; """Keyboard event handler.""" if event.key() == Qt.Key_Escape and self.play_button.isChecked(): self.play_button.toggle() self.browser_button.setChecked((True, False)[self.reset_browser]) elif (event.key() in [16777220, 16777221] and self.view.g_rect.rect().width() > 0): self.view.got_rect.emit((self.view.g_rect.rect().topLeft(), self.view.g_rect.rect().bottomRight())) if self.view.g_rect.pen().color() == Qt.red: new_pix = self.pixmap.pixmap().copy(self.crop_rect) if self.pixmap_is_scaled: new_pix = new_pix.transformed( self.view.transform().inverted()[0], Qt.SmoothTransformation) self.update_pixmap(new_pix) elif self.view.g_rect.pen().color() == Qt.magenta: self.annotate_rect() self.view.annotation = False for _ in (self.label, self.save_button, self.no_save_button): _.show() self.view.reset() def play(self): """Starts a slideshow.""" if self.play_button.isChecked(): if self.browser_button.isChecked(): self.reset_browser = True else: self.reset_browser = False QTimer.singleShot(3000, self.play) self.next() def _yield_radio(self): """Saves code connecting signals from all the radio buttons.""" yield from self.radios.keys().__str__() def load_icon(self, icon_file): """Loads an icon from Base64 encoded strings in icons.py.""" pix = QPixmap() pix.loadFromData(icon_file) return QIcon(pix) def open_file(self, file: str) -> None: """ Open an image file and display it. :param file: The filename of the image to open """ if not os.path.isfile(file): file = QFileDialog(self, self.dir_now, self.dir_now).getOpenFileName()[0] self.dir_now = os.path.dirname(file) self.refresh_files() for i, index_file in enumerate(self.files): if file.split('/')[-1] == index_file: self.index = i self.view.setTransform(QTransform()) self.update_pixmap(QPixmap(file)) self.browser() self.load_tags() def refresh_files(self) -> list[str]: """Updates the file list when the directory is changed. Returns a list of image files available in the current directory.""" files = os.listdir(self.dir_now) self.files = [ file for file in sorted(files, key=lambda x: x.lower()) if file.endswith((".png", ".jpg", ".gif", ".bmp", ".jpeg")) ] def next(self) -> None: """Opens the next image in the file list.""" self.index = (self.index + 1) % len(self.files) self.reload() def previous(self) -> None: """Opens the previous image in the file list.""" self.index = (self.index + (len(self.files) - 1)) % len(self.files) self.reload() def save_image(self) -> None: """ Save the modified image file. If the current pixmap has been scaled, we need to load a non-scaled pixmap from the original file and re-apply the transformations that have been performed to prevent it from being saved as the scaled-down image. """ if self.pixmap_is_scaled: rotation = self.pixmap.rotation() mirror = self.pixmap.scale() < 0 pix = QPixmap(self.files[self.index]) pix = pix.transformed(QTransform().rotate(rotation)) if mirror: pix = pix.transformed(QTransform().scale(-1, 1)) pix.save(self.files[self.index], quality=-1) else: self.pixmap.pixmap().save(self.files[self.index], quality=-1) self.save_tags() def delete(self) -> None: """Deletes the current image from the file system.""" with suppress(OSError): os.remove(f"{self.dir_now}/{self.files.pop(self.index)}") self.refresh_files() def reload(self) -> None: """Reloads the current pixmap; used to update the screen when the current file is changed.""" self.open_file(f"{self.dir_now}/{self.files[self.index]}") def annotate(self, tag): """Starts an annotate action""" self.txt = PngInfo() self.view.annotation = True self.active_tag = tag self.view.reset() def wheelEvent(self, event: QWheelEvent) -> None: # pylint: disable=invalid-name """With Ctrl depressed, zoom the current image, otherwise fire the next/previous functions.""" modifiers = QApplication.keyboardModifiers() if event.angleDelta().y() == 120 and modifiers == Qt.ControlModifier: self.view.scale(0.75, 0.75) elif event.angleDelta().y() == 120: self.previous() elif event.angleDelta().y( ) == -120 and modifiers == Qt.ControlModifier: self.view.scale(1.25, 1.25) elif event.angleDelta().y() == -120: self.next() def actual_size(self) -> None: """Display the current image at its actual size, rather than scaled to fit the viewport.""" self.update_pixmap(QPixmap(self.files[self.index]), False) self.view.setDragMode(QGraphicsView.ScrollHandDrag) def mousePressEvent(self, event: QMouseEvent) -> None: # pylint: disable=invalid-name """Event handler for mouse button presses.""" if event.button() == Qt.MouseButton.ForwardButton: self.next() elif event.button() == Qt.MouseButton.BackButton: self.previous() def update_pixmap(self, new: QPixmap, scaled: bool = True) -> None: """ Updates the currently displayed image. :param new: The new `QPixmap` to be displayed. :param scaled: If False, don't scale the image to fit the viewport. """ self.pixmap_is_scaled = scaled self.media.clear() self.pixmap = self.media.addPixmap(new) self.pixmap.setTransformOriginPoint( self.pixmap.boundingRect().width() / 2, self.pixmap.boundingRect().height() / 2) if scaled and (new.size().width() > self.view.width() or new.size().height() > self.view.height()): self.view.fitInView(self.pixmap, Qt.KeepAspectRatio) self.media.setSceneRect(self.pixmap.boundingRect()) def annotate_rect(self): """Creates image coordinate annotation data.""" self.txt.add_itxt( f'{str(self.active_tag)}-rect', f'{str(self.crop_rect.x())}, {str(self.crop_rect.y())}, {str(self.crop_rect.width())}, {str(self.crop_rect.height())}' ) def browser(self): """Slot function to initialize image thumbnails for the 'browse mode.'""" while self.h_box2.itemAt(0): self.h_box2.takeAt(0).widget().deleteLater() index = (self.index + (len(self.files) - 2)) % len(self.files) for i, file in enumerate(self.files): file = self.dir_now + '/' + self.files[index] label = ClickableLabel(self, file) self.h_box2.addWidget(label) pix = QPixmap(file) if (pix.size().width() > self.browse_bar.width() / 5 or pix.size().height() > 100): pix = pix.scaled(self.browse_bar.width() / 5, 100, Qt.KeepAspectRatio) label.setPixmap(pix) index = (index + 1) % len(self.files) if i == 4: break def save_tags(self): """Save tags for currently loaded image into its iTxt data.""" file = self.files[self.index] img = Image.open(file) img.load() for key, value, in img.text.items(): self.txt.add_itxt(key, value) for key in self.radios: if key.isChecked(): self.txt.add_itxt(self.radios[key]['this'], 'True') self.txt.add_itxt(self.radios[key]['that'], 'False') img.save(file, pnginfo=self.txt) def load_tags(self): """Load tags from iTxt data.""" for radio in self.radios: if radio.isChecked(): self.radios[radio]['reset'].setChecked(True) filename = self.files[self.index] fqp = filename img = Image.open(fqp) img.load() with suppress(AttributeError): for key, value in img.text.items(): if value == 'True': for radio in self.radios: if key == self.radios[radio]['this']: radio.setChecked(True) self.view.annotation = False self.active_tag = '' self.view.reset() for key, value in img.text.items(): if key.endswith('-rect'): btn = [ radio for radio in self.radios if self.radios[radio]['this'] == key.split('-')[0] ] print(key, value) if btn[0].isChecked(): coords = [int(coord) for coord in value.split(', ')] rect = QGraphicsRectItem(*coords) rect.setPen(QPen(Qt.magenta, 1, Qt.SolidLine)) rect.setBrush(QBrush(Qt.magenta, Qt.Dense4Pattern)) self.view.scene().addItem(rect) text = self.view.scene().addText( key.split('-')[0], QFont('monospace', 20, 400, False)) text.font().setPointSize(text.font().pointSize() * 2) text.update() text.setX(rect.rect().x() + 10) text.setY(rect.rect().y() + 10) print(f'set {key}')
def generateComics( self, instance ): image = Image.new( mode="1", size=(0, 0) ) for self.generatedComicNumber in range( self.numberOfComics ): try: if self.commandLineComicID is None: wordBubbleFileName = random.choice( os.listdir( self.wordBubblesDir ) ) else: wordBubbleFileName = os.path.join( self.wordBubblesDir, self.commandLineComicID + ".tsv" ) except IndexError as error: six.print_( error, file=sys.stderr ) exit( EX_NOINPUT ) if not self.silence: six.print_( "wordBubbleFileName:", wordBubbleFileName ) if self.commandLineComicID is None: comicID = os.path.splitext( wordBubbleFileName )[ 0 ] else: comicID = self.commandLineComicID wordBubbleFileName = os.path.join( self.wordBubblesDir, wordBubbleFileName ) if not self.silence: six.print_( "Loading word bubbles from", wordBubbleFileName ) try: wordBubbleFile = open( wordBubbleFileName, mode="rt" ) except OSError as error: six.print_( error, file=sys.stderr ) exit( EX_NOINPUT ) if not idChecker.checkFile( wordBubbleFile, wordBubbleFileName, self.commentMark ): six.print_( "Error: Word bubble file", wordBubbleFileName, "is not in the correct format." ) exit( EX_DATAERR ) lookForSpeakers = True speakers = [] while lookForSpeakers: line = wordBubbleFile.readline() if len( line ) > 0: line = line.partition( self.commentMark )[0].strip() if len( line ) > 0: speakers = line.upper().split( "\t" ) if len( speakers ) > 0: lookForSpeakers = False else: lookForSpeakers = False; #End of file reached, no speakers found if len( speakers ) == 0: six.print_( "Error: Word bubble file", wordBubbleFileName, "contains no speakers." ) exit( EX_DATAERR ) if not self.silence: six.print_( "These characters speak:", speakers ) for speaker in speakers: if speaker not in self.generators: if not self.silence: six.print_( "Now building a Markov graph for character", speaker, "..." ) newGenerator = Generator( charLabel = speaker, cm = self.commentMark, randomizeCapitals = self.randomizeCapitals ) newGenerator.buildGraph( self.inDir ) if not self.silence: newGenerator.showStats() self.generators[ speaker ] = newGenerator if not self.silence: six.print_( comicID ) inImageFileName = os.path.join( self.imageDir, comicID + ".png" ) try: image = Image.open( inImageFileName ).convert() #Text rendering looks better if we ensure the image's mode is not palette-based. Calling convert() with no mode argument does this. except IOError as error: six.print_( error, file=sys.stderr ) exit( EX_NOINPUT ) transcript = str( comicID ) + "\n" previousBox = ( int( -1 ), int( -1 ), int( -1 ), int( -1 ) ) #For detecting when two characters share a speech bubble; don't generate text twice. for line in wordBubbleFile: line = line.partition( self.commentMark )[ 0 ].strip() if len( line ) > 0: line = line.split( "\t" ) character = line[ 0 ].rstrip( ":" ).strip().upper() try: generator = self.generators[ character ] except: six.print_( "Error: Word bubble file", wordBubbleFileName, "does not list", character, "in its list of speakers.", file=sys.stderr ) exit( EX_DATAERR ) topLeftX = int( line[ 1 ] ) topLeftY = int( line[ 2 ] ) bottomRightX = int( line[ 3 ] ) bottomRightY = int( line[ 4 ] ) box = ( topLeftX, topLeftY, bottomRightX, bottomRightY ) if box != previousBox: previousBox = box text = "" nodeList = generator.generateSentences( 1 )[ 0 ] for node in nodeList: text += node.word + " " text.rstrip() oneCharacterTranscript = character + ": " oneCharacterTranscript += self.stringFromNodes( nodeList ) if not self.silence: six.print_( oneCharacterTranscript ) oneCharacterTranscript += "\n" transcript += oneCharacterTranscript wordBubble = image.crop( box ) draw = ImageDraw.Draw( wordBubble ) width = bottomRightX - topLeftX if width <= 0: #Width must be positive width = 1 height = bottomRightY - topLeftY if height <= 0: height = 1 size = int( height * 1.2 ) #Contrary to the claim by PIL's documentation, font sizes are apparently in pixels, not points. The size being requested is the height of a generic character; the actual height of any particular character will be approximately (not exactly) the requested size. We will try smaller and smaller sizes in the while loop below. The 1.2, used to account for the fact that real character sizes aren't exactly the same as the requested size, I just guessed an appropriate value. normalFont = ImageFont.truetype( self.normalFontFile, size = size ) boldFont = ImageFont.truetype( self.boldFontFile, size = size ) listoflists = self.rewrap_nodelistlist( nodeList, normalFont, boldFont, width, fontSize = size ) margin = 0 offset = originalOffset = 0 goodSizeFound = False while not goodSizeFound: goodSizeFound = True totalHeight = 0 for line in listoflists: lineWidth = 0 lineHeight = 0 for node in line: wordSize = normalFont.getsize( node.word + " " ) lineWidth += wordSize[ 0 ] lineHeight = max( lineHeight, wordSize[ 1 ] ) lineWidth -= normalFont.getsize( " " )[ 0 ] totalHeight += lineHeight if lineWidth > width: goodSizeFound = False if totalHeight > height: goodSizeFound = False if not goodSizeFound: size -= 1 try: normalFont = ImageFont.truetype( self.normalFontFile, size = size ) boldFont = ImageFont.truetype( self.boldFontFile, size = size ) except IOError as error: six.print_( error, "\nUsing default font instead.", file=sys.stderr ) normalFont = ImageFont.load_default() boldFont = ImageFont.load_default() listoflists = self.rewrap_nodelistlist( nodeList, normalFont, boldFont, width, fontSize = size ) midX = int( wordBubble.size[ 0 ] / 2 ) midY = int( wordBubble.size[ 1 ] / 2 ) try: #Choose a text color that will be visible against the background backgroundColor = ImageStat.Stat( wordBubble ).mean #wordBubble.getpixel( ( midX, midY ) ) textColorList = [] useIntegers = False useFloats = False if wordBubble.mode.startswith( "1" ): bandMax = 1 useIntegers = True elif wordBubble.mode.startswith( "L" ) or wordBubble.mode.startswith( "P" ) or wordBubble.mode.startswith( "RGB" ) or wordBubble.mode.startswith( "CMYK" ) or wordBubble.mode.startswith( "YCbCr" ) or wordBubble.mode.startswith( "LAB" ) or wordBubble.mode.startswith( "HSV" ): bandMax = 255 useIntegers = True elif wordBubble.mode.startswith( "I" ): bandMax = 2147483647 #max for a 32-bit signed integer useIntegers = True elif wordBubble.mode.startswith( "F" ): bandMax = float( "infinity" ) useFloats = True else: #I've added all modes currently supported according to Pillow documentation; this is for future compatibility bandMax = max( ImageStat.Stat( image ).extrema ) for c in backgroundColor: d = bandMax - ( c * 1.5 ) if d < 0: d = 0 if useIntegers: d = int( d ) elif useFloats: d = float( d ) textColorList.append( d ) if wordBubble.mode.endswith( "A" ): #Pillow supports two modes with alpha channels textColorList[ -1 ] = bandMax textColor = tuple( textColorList ) except ValueError: textColor = "black" offset = originalOffset for line in listoflists: xOffset = 0 yOffsetAdditional = 0 for node in line: usedFont = node.font draw.text( ( margin + xOffset, offset ), node.word + " ", font = usedFont, fill = textColor ) tempSize = usedFont.getsize( node.word + " " ) xOffset += tempSize[ 0 ] yOffsetAdditional = max( yOffsetAdditional, tempSize[ 1 ] ) node.unselectStyle() offset += yOffsetAdditional image.paste( wordBubble, box ) wordBubbleFile.close() if self.numberOfComics > 1: oldOutTextFileName = self.outTextFileName temp = os.path.splitext(self.outTextFileName ) self.outTextFileName = temp[ 0 ] + str( self.generatedComicNumber ) + temp[ 1 ] #---------------------------Split into separate function try: #os.makedirs( os.path.dirname( outTextFileName ), exist_ok = True ) outFile = open( self.outTextFileName, mode="wt" ) except OSError as error: six.print_( error, "\nUsing standard output instead", file=sys.stderr ) outFile = sys.stdout if self.numberOfComics > 1: self.outTextFileName = oldOutTextFileName six.print_( transcript, file=outFile ) outFile.close() if self.numberOfComics > 1: oldOutImageFileName = self.outImageFileName temp = os.path.splitext( self.outImageFileName ) outImageFileName = temp[ 0 ] + str( self.generatedComicNumber ) + temp[ 1 ] if self.topImageFileName != None: try: topImage = Image.open( self.topImageFileName ).convert( mode=image.mode ) except IOError as error: six.print_( error, file=sys.stderr ) exit( EX_NOINPUT ) oldSize = topImage.size size = ( max( topImage.size[ 0 ], image.size[ 0 ] ), topImage.size[ 1 ] + image.size[ 1 ] ) newImage = Image.new( mode=image.mode, size=size ) newImage.paste( im=topImage, box=( 0, 0 ) ) newImage.paste( im=image, box=( 0, oldSize[ 1 ] ) ) image = newImage originalURL = None URLFile = open( os.path.join( self.inDir, "sources.tsv" ), "rt" ) for line in URLFile: line = line.partition( self.commentMark )[ 0 ].strip() if len( line ) > 0: line = line.split( "\t" ) if comicID == line[ 0 ]: originalURL = line[ 1 ] break; URLFile.close() transcriptWithURL = transcript + "\n" + originalURL #The transcript that gets embedded into the image file should include the URL. The transcript that gets uploaded to blogs doesn't need it, as the URL gets sent anyway. infoToSave = PngInfo() encodingErrors = "backslashreplace" #If we encounter errors during text encoding, I feel it best to replace unencodable text with escape sequences; that way it may be possible for reader programs to recover the original unencodable text. #According to the Pillow documentation, key names should be "latin-1 encodable". I take this to mean that we ourselves don't need to encode it in latin-1. key = "transcript" keyUTF8 = key.encode( "utf-8", errors=encodingErrors ) #uncomment the following if using Python 3 #transcriptISO = transcriptWithURL.encode( "iso-8859-1", errors=encodingErrors ) #transcriptUTF8 = transcriptWithURL.encode( "utf-8", errors=encodingErrors ) #python 2: tempencode = transcriptWithURL.decode( 'ascii', errors='replace' ) # I really don't like using this ascii-encoded intermediary called tempencode, but i couldn't get the program to work when encoding directly to latin-1 transcriptISO = tempencode.encode( "iso-8859-1", errors='replace' ) transcriptUTF8 = tempencode.encode( "utf-8", errors='replace' ) infoToSave.add_itxt( key=key, value=transcriptUTF8, tkey=keyUTF8 ) infoToSave.add_text( key=key, value=transcriptISO ) #GIMP only recognizes comments key = "Comment" keyUTF8 = key.encode( "utf-8", errors=encodingErrors ) infoToSave.add_text( key=key, value=transcriptISO ) infoToSave.add_itxt( key=key, value=transcriptUTF8, tkey=keyUTF8 ) try: #os.makedirs( os.path.dirname( outImageFileName ), exist_ok = True ) if self.saveForWeb: image = image.convert( mode = "P", palette="ADAPTIVE", dither=False ) #Try turning dithering on or off. image.save( self.outImageFileName, format="PNG", optimize=True, pnginfo=infoToSave ) else: image.save( self.outImageFileName, format="PNG", pnginfo=infoToSave ) except IOError as error: six.print_( error, file = sys.stderr ) exit( EX_CANTCREAT ) except OSError as error: six.print_( error, file = sys.stderr ) exit( EX_CANTCREAT ) if not self.silence: six.print_( "Original comic URL:", originalURL ) for blog in self.blogUploaders: blog.upload( postStatus = "publish", inputFileName = outImageFileName, shortComicTitle = self.shortName, longComicTitle = self.longName, transcript = transcript, originalURL = originalURL, silence = self.silence ) if self.numberOfComics > 1: outImageFileName = oldOutImageFileName #end of loop: for generatedComicNumber in range( numberOfComics ): #---------------------------It's display time! if image.mode != "RGB": image = image.convert( mode = "RGB" ) self.gui.comicArea.texture = Texture.create( size = image.size, colorfmt = 'rgb' ) self.gui.comicArea.texture.blit_buffer( pbuffer = image.transpose( Image.FLIP_TOP_BOTTOM ).tobytes(), colorfmt = 'rgb' )