PyWinAuto + Maya 3D — записки начинающего автоматизатора

Почему важно узнавать подробности до старта работы

Появилась задачка: взять примерно сто тридцать шотов, настроить в них освещение, пофиксить проблемы при наличии, отправить на рендер. Софт — Autodesk Maya, а каждый шот представляет из себя отдельный файл с анимацией и всеми пирогами. И так двадцать пять раз, потому что двадцать пять эпизодов.
Когда я брался за задачку, наивно посчитал, что можно выкатывать эпизод в месяц. Примерно те же тайминги озвучили заказчики.
Жестоко ошибся: сначала стоило выяснить рабочую процедуру, благо есть у кого.
Выяснив её, я пришёл в ужас. По всему выходило, что на один эпизод улетает от полутора месяцев.

Как жить, когда ты уже не узнал подробностей

Поборов в себе судорожное желание найти новых заказчиков, сел думать, что можно сделать в этих обстоятельствах.

Я задокументировал процесс, и расписал временные ресурсы.
На множестве операций 3D-артист простаивает. Для примера, одна лишь загрузка Maya занимает минуту-две.

Звучит смешно, понимаю. Смешно перестаёт быть при взгляде на эту табличку:

109 часов только на открытие! Это почти месяц.109 часов только на открытие! Это почти месяц.

Все шесть операций человек делает пару кликов на операцию, а потом задумчиво тупит в монитор. Очень не люблю такой формат работы, он высаживает всё внимание за пару часов.

Оптимизировать пайплайн проблематично: модельки русские, анимация иранская, собирается это всё и вовсе третьей стороной, плагины под проект написаны также иранцами.
Лезть во всё это великолепие руками страшно: во-первых, есть риск заблудиться навсегда, а во-вторых, убрав паузу из какого-нибудь батника можно невзначай всю телегу поломать.

Зато можно имитировать оператора ПК питоном! Я собрал у коллег информацию об ошибках, возникающих в процессе работы. Убедился в том, что большая их часть — типовые, а также не помешают работе скрипта. И начал продумывать план побега, который скостит время простоя оператора.

Первым делом разметил таски двумя цветами: обязательное присутствие мозга при выполнении, и необязательное.

Так я вычленил операции, которые можно безболезненно перевесить на скрипт.

Как решалось

Несколько раз пользовавшись библиотекой PyWinAuto, обратился к ней опять.
Людей на рендере много и у них разные машины, потому координатный подход к автоматизации отмёлся — буду ловить кнопки по названиям.

Поехали

Импортнём нужные библиотеки.

import pywinauto as pw          # для работы с GUI
import os                       # для работы с файлами и папками
from time import sleep          # для таймеров
import logging                  # для ведения лога
import sys                      # для вывода информации в терминал
from PIL import ImageGrab       # для скриншотов
from datetime import datetime   # для записи времени в название скриншота
import matplotlib.pyplot as plt # на случай острого желания построить пару графиков

Первое, чего захочется — безудержного логирования! Возможно, даже со скриншотами. Но это не точно, поэтому повесим скриншот на булинь.

def log(file_name, massage, screenshot = False):
    
    logging.basicConfig(
        format="%(asctime)s %(message)s",
        encoding='utf-8',
        level=logging.DEBUG,
        handlers=[
        logging.FileHandler("log\\" + file_name + '.log'),
        logging.StreamHandler(sys.stdout)
    ])
    logging.debug(massage)
    
    if screenshot == True:
        now = datetime.now()
        current_time = now.strftime("%H_%M_%S_")
        myscreen = ImageGrab.grab()
        myscreen.save("log\\" + current_time + file_name + '.jpg')

Второе — получить список файлов с явками и паролями. Исходные файлы лежат в отдельной папке рядом со скриптом, так что всё удобно.

def get_files_list():

    path = os.getcwd()  # подхватываем адрес рабочей папки
    files = os.listdir(path + '\\animate')  # собираем список на обработку

    return(files)

Третье — обработать каждый файлик. Соберу конструкцию, которую дальше буду дополнять.

def process():

    files = get_files_list()

    for file in files:
        open_shot(file)
        baking_animation_curves(file)
        get_assembly(file)

Откроем файл в лоб через проводник. Допускаю, что есть более простые методы.

def open_shot(file):

    pw.Application().start('explorer.exe "C:\\Program Files"')  # стартуем проводник
    app = pw.Application(backend="uia").connect(path="explorer.exe", title="Program Files")  # подключаемся к нему
    dlg = app["Program Files"]  # подключаемся к окошку
    dlg['Address: C:\Program Files'].wait("ready")  # ждём готовности поисковой строки

    dlg.type_keys("%D")    # переключаемся на неё хоткеем 
    dlg.type_keys(file, with_spaces=True)    # ввпечатываем туда файл
    dlg.type_keys("{ENTER}")

    app.kill()  # закрываем проводник

Окно Майи появляется секунд через тридцать после обращения к файлу. Напишем функцию, которая проверит появление окна, и заодно даст доступ скрипту к Майе.

def maya_connect():
  
    try:
      	# пробуем подключиться к окошку:
        app = pw.Application(backend="uia").connect(title_re=".* - Autodesk Maya 2020.4:")
        print("Connected!")
        return app
      	# если окошка нет:
    except pw.findwindows.ElementNotFoundError:
      	# Можно взять просто time.sleep(), но так мы
      	# дадим понять пользователю, что скрипт не висит
        for i in ['.  ', '.. ', '...']: 
            sys.stdout.write('\r'"Сonnecting" + i)
            sleep(1)
            
        return maya_connect()

Теперь надобно понять, что файл открылся, и с ним можно работать. Вариант повесить на таймер не годится: файлы весят по-разному, с-во берут разное время на загрузку.
PyWinAuto предлагает app.wait_cpu_usage_lower(threshold=5) — план хорош!
Проверим, как выглядит кривая потребления CPU Майей:

код

def get_app_load (app):

load_points = [1] 	# список для записи загрузки
										# начинается с единицы, чтобы сработал цикл
timer = [0]					# список для подсчёта секунд со старта записи нагрузки
n = 0								# счётчик времени

# пока сумма значений нагрузки за последние 60 секунд не станет нулевой,
# собираем значения
while sum(load_points[-60:]) != 0:
            
    load = app.cpu_usage()
    
    if load < 0.5:  # убираем случайные всплески
      load = 0
    
    load_points.append(load)
    n += 1
    timer.append(n)
    sleep(1)

plt.plot(timer, load_points)
plt.show()

Запуск MayaЗапуск Maya

Майя дискретно освобождает ресурсы процессора в процессе работы, так что ждать cpu_lower смысла нет.

Совместим таймер и чек нагрузки на процессор:

def app_status(app):
    
    """если последние 30 секунд Майя не трогала процессор,
    будем считать, что она освободилась"""
    
    load_points = [1]
    while sum(load_points[-30:]) != 0:
        load = app.cpu_usage()
        if load < 0.5:  # убираем случайные всплески
            load = 0
            
        load_points.append(load)
        
        for i in ['.  ', '.. ', '...']: 
            sys.stdout.write('\r'"Maya is working now" + i)
            sleep(0.33)

Будем пользовать эту конструкцию для ожидания конца операций.

Файл открыли, можно теперь и автоматизировать что-нибудь

…предварительно обложившись инструментами. Нам понадобится:

Функция, печатающая дерево элементов в выбранном диалоге:

# для печати в файл
dlg.print_control_identifiers(depth = None, filename = "MayaControls.txt")

# или просто вывода
dlg.print_control_identifiers(depth = None)

И функция, рисующая зелёный прямоугольник вокруг выбранного элемента:

# или не зелёный, тут как настроишь :)
dlg.draw_outline(colour='green')
dlg['ShowMenuItem2'].draw_outline(colour='red')
dlg.MultyDoTask.draw_outline(colour='blue')

Как обращаться к элементам

Диалог — dlg — это коробка с графическими элементами. А главное окно приложения — тоже диалог, потому обращаться к интересующему можно через dlg = pw.Desktop(backend="uia")["Anything"].
Независимо от способа работы с диалогом, через connect() или Desktop, понадобится определить, что этот диалог отличает от собратьев. Часто достаточно названия, отображаемого на экране.

Внутри диалога лежит не просто графика, а контроллеры: кнопки, чекбоксы, тому подобное и… другие диалоги.

Подключаемся к диалогу:

# так:
dlg = pw.Desktop(backend="uia")["Untitled - Notepad"]

# либо так:
app = pw.application.Application(backend="uia").connect(title = "Untitled - Notepad")
dlg = app["Untitled - Notepad"]

Затем ищем и что-то делаем с необходимым элементом:

dlg["File"].click_input()  # клик по кнопке "File"

Питон шибко быстрее графического интерфейса, а тем более, монструозной софтины. Так что перед совершением действия просим скрипт подождать:

# всё вместе, или любая комбинация:
dlg["File"].wait("exists enabled visible ready active").click_input()

Eсли искомая кнопка в интерфейсе легко находится скриптом по названию — всё ок.
Если находится не та — печатаем идентификаторы, находим уникальное имя кнопки —  и всё опять ок.

Дерево идентификаторов может выглядеть странно.
В первую голову надо удостовериться в правильно выбранном бэкенде при подключении к приложению:

На иллюстрации Notepad, пушто дерево элементов Майи очень ветвистое.На иллюстрации Notepad, пушто дерево элементов Майи очень ветвистое.

Нет нужды выводить элементы всего окошка. Стоит выбрать только интересующую область.

Ну, а кроме того, можно настроить глубину раскопок:

# так в вывод уйдут только первые две ступени
dlg["File"].print_control_identifiers(depth = 2)

На иллюстрации Notepad, пушто дерево элементов Майи очень ветвистое.На иллюстрации Notepad, пушто дерево элементов Майи очень ветвистое.

Этих знаний достаточно для натыка по Maya 3D, а так — возможности PyWinAuto гораздо шире.
Натыкиваем операции, вставляем между ними проверку потребления ресурсов, и отпускаем скрипт в свободное плавание!

Напоследок

При массовой обработке файлов очень, очень стоит заворачивать конструкции в Try/Except: обидно узнать, что скрипт крашнулся в два часа ночи, и всю ночь машинка стояла без дела.

Проект бодро ползёт к финалу.
Кроме процессов, мы оптимизировали сцены, а это тоже славно режет время на обработку.
Хочу сказать спасибо разработчику PyWinAuto, потому что без него яб лёг и умер.

© Habrahabr.ru