diff --git a/docs/source/cli.rst b/docs/source/cli.rst index 5bb0b15..a2734b8 100644 --- a/docs/source/cli.rst +++ b/docs/source/cli.rst @@ -42,5 +42,13 @@ Command Line Reference +--------------------+----------------+------------------------------------------------------------------+ | -d, --docx | DOCX file | Path to a Word document to export to | +--------------------+----------------+------------------------------------------------------------------+ +| -im, --image | PNG file | Path to an image file (PNG format) to export to | ++--------------------+----------------+------------------------------------------------------------------+ +| -hm, --halfmoves | Halfmoves | Halfmove number to export an image at | ++--------------------+----------------+------------------------------------------------------------------+ +| -mov, --movie | MP4 file | Path to a movie file (MP4 format) to export to | ++--------------------+----------------+------------------------------------------------------------------+ +| -du, --duration | Seconds | Movie frame duration in seconds | ++--------------------+----------------+------------------------------------------------------------------+ | -vb, --verbose | | Write analysis details to the console during analysis | +--------------------+----------------+------------------------------------------------------------------+ diff --git a/docs/source/code/chess_analyser.reporting.rst b/docs/source/code/chess_analyser.reporting.rst index c6543eb..e941a48 100644 --- a/docs/source/code/chess_analyser.reporting.rst +++ b/docs/source/code/chess_analyser.reporting.rst @@ -44,6 +44,14 @@ chess\_analyser.reporting.images module :show-inheritance: :undoc-members: +chess\_analyser.reporting.movies module +--------------------------------------- + +.. automodule:: chess_analyser.reporting.movies + :members: + :show-inheritance: + :undoc-members: + chess\_analyser.reporting.search module --------------------------------------- diff --git a/docs/source/data_export.rst b/docs/source/data_export.rst new file mode 100644 index 0000000..d0e05b1 --- /dev/null +++ b/docs/source/data_export.rst @@ -0,0 +1,51 @@ +Data Export +=========== + + +Exporting Analysis Results +-------------------------- + +To export the analysis for a game that's been loaded and analysed, use the following options: + +.. code-block:: bash + + run.sh --export --reference "" --engine --xlsx + run.sh --export --reference "" --engine --docx + run.sh --export --reference "" --engine --pgn + +The first form exports a report in XLSX format to the specified spreadsheet, the second exports a report in +DOCX format to the specified document file and the final form writes a PGN file for the game annotated with +the evaluation and annotations for each move. + +If required, multiple outputs can be specified in a single export command: + +.. code-block:: bash + + run.sh --export --reference "" --engine --xlsx --docx --pgn + +This command exports the analysis in both XLSX and DOCX format and writes the annotated PGN file. + +Exporting Images of the Board +----------------------------- + +To export a PNG format image of the board position for a game that's been loaded, but not necessarily analysed, use the following options: + +.. code-block:: bash + + run.sh --export --reference "" --image "" --halfmoves + + +"" indicates the number of halfmoves to fast-forward by before exporting the image. It may also have the value "*" to fast-forward +to the end of the game and export the final position. + +Exporting Movies of a Game +-------------------------- + +To export an MP4 format move of a game that's been loaded, but not necessarily analysed, use the following options: + +.. code-block:: bash + + run.sh --export --reference "" --movie "" --duration + + +"" is the number of seconds for which each move in the game is displayed. The duration should be a positive number e.g. 0.5, 1. diff --git a/docs/source/index.rst b/docs/source/index.rst index 0eeb9ec..f34be04 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -26,6 +26,7 @@ algorithms from Lichess [#2]_ and En Croissant [#3]_ to the per-move analysis re engines database workflow + data_export metadata_search data_management scoring diff --git a/docs/source/setup.rst b/docs/source/setup.rst index 913dab8..421c999 100644 --- a/docs/source/setup.rst +++ b/docs/source/setup.rst @@ -11,6 +11,8 @@ The Chess Analysis application requires the following: - Local copies of the chess engines (see the documentation on engine installation) - A configured SQLite database (see the documentation on the database) +For movie export, then `ffmpeg `_ must also be installed. + Creating a Virtual environment ------------------------------ @@ -40,3 +42,4 @@ cannot load library 'libcairo-2.dll': dlopen(libcairo-2.dll, 2): image not found ``` - This can be installed using Homebrew or MacPorts + diff --git a/docs/source/workflow.rst b/docs/source/workflow.rst index 3b2e97d..2bf24f7 100644 --- a/docs/source/workflow.rst +++ b/docs/source/workflow.rst @@ -68,41 +68,4 @@ Where: - "--winchance" tabulates the data used to generate a "Win%" chart [#1]_ - "--info" tabulates the game headers, read from the PGN file -Exporting Analysis Results --------------------------- - -To export the analysis for a game that's been loaded and analysed, use the following options: - -.. code-block:: bash - - run.sh --export --reference "" --engine --xlsx - run.sh --export --reference "" --engine --docx - run.sh --export --reference "" --engine --pgn - -The first form exports a report in XLSX format to the specified spreadsheet, the second exports a report in -DOCX format to the specified document file and the final form writes a PGN file for the game annotated with -the evaluation and annotations for each move. - -If required, multiple outputs can be specified in a single export command: - -.. code-block:: bash - - run.sh --export --reference "" --engine --xlsx --docx --pgn - -This command exports the analysis in both XLSX and DOCX format and writes the annotated PGN file. - -Exporting Images of the Board ------------------------------ - -To export a PNG format image of the board position for a game that's been loaded, but not necessarily analysed, use the following options: - -.. code-block:: bash - - run.sh --export --reference "" --image "" --halfmoves - - -"" indicates the number of halfmoves to fast-forward by before exporting the image. It may also have the value "*" to fast-forward -to the end of the game and export the final position. - - .. [#1] `Lichess win% calculation `_ \ No newline at end of file diff --git a/requirements.txt b/requirements.txt index e97dd07..8ebba42 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,27 +6,34 @@ chess==1.11.2 contourpy==1.3.1 cssselect2==0.8.0 cycler==0.12.1 +decorator==5.2.1 defusedxml==0.7.1 fonttools==4.56.0 greenlet==3.1.1 +imageio==2.37.0 +imageio-ffmpeg==0.6.0 kiwisolver==1.4.8 lxml==5.3.1 Mako==1.3.9 MarkupSafe==3.0.2 matplotlib==3.10.1 +moviepy==2.1.2 numpy==2.2.4 packaging==24.2 pandas==2.2.3 -pillow==11.1.0 +pillow==10.4.0 +proglog==0.1.11 pycparser==2.22 pyparsing==3.2.3 python-chess==1.999 python-dateutil==2.9.0.post0 python-docx==1.1.2 +python-dotenv==1.1.0 pytz==2025.2 six==1.17.0 SQLAlchemy==2.0.40 tinycss2==1.4.0 +tqdm==4.67.1 typing_extensions==4.13.0 tzdata==2025.2 webencodings==0.5.1 diff --git a/src/chess_analyser/cli/dispatcher.py b/src/chess_analyser/cli/dispatcher.py index 3579a89..0ca3c5a 100644 --- a/src/chess_analyser/cli/dispatcher.py +++ b/src/chess_analyser/cli/dispatcher.py @@ -1,12 +1,12 @@ import argparse from ..reporting import tabulate_analysis, tabulate_summary, tabulate_win_chance, \ write_analysis_spreadsheet, write_analysis_document, tabulate_players, \ - tabulate_game_info, search_metadata, write_board_position_image + tabulate_game_info, search_metadata, export_board_image_after_halfmoves, export_movie from ..analysis.analysis import analyse_game from ..constants import PROGRAM_NAME, PROGRAM_DESCRIPTION, PROGRAM_VERSION, OPT_LOAD, OPT_ANALYSE, \ OPT_RESULTS, OPT_WHITE, OPT_BLACK, OPT_SUMMARY, OPT_WIN_CHANCE, OPT_EXPORT, OPT_PLAYERS, OPT_INFO, \ OPT_SEARCH, OPT_DELETE, OPT_VERSION, OPT_ENGINE, OPT_PGN, OPT_REFERENCE, OPT_IMAGE, \ - OPT_VERBOSE, OPT_XLSX, OPT_DOCX, OPT_HALFMOVES + OPT_VERBOSE, OPT_XLSX, OPT_DOCX, OPT_HALFMOVES, OPT_MOVIE, OPT_DURATION from ..pgn import import_pgn, export_pgn from ..management import GAME, ANALYSIS, delete_data @@ -43,8 +43,10 @@ def configure_parser(): parser.add_argument("-ref", "--reference", nargs=1, help="Reference for an imported game") parser.add_argument("-x", "--xlsx", nargs=1, help="Path to an XLSX file to export to") parser.add_argument("-d", "--docx", nargs=1, help="Path to a Word document to export to") - parser.add_argument("-im", "--image", nargs=1, help="Path to ain image file (PGN format) to export to") - parser.add_argument("-hm", "--halfmoves", nargs=1, help="Halfmove number to export at") + parser.add_argument("-im", "--image", nargs=1, help="Path to an image file (PNG format) to export to") + parser.add_argument("-hm", "--halfmoves", nargs=1, help="Halfmove number to export an image at") + parser.add_argument("-mov", "--movie", nargs=1, help="Path to a movie file (MP4 format) to export to") + parser.add_argument("-du", "--duration", nargs=1, help="Movie frame duration in seconds") # Flags parser.add_argument("-vb", "--verbose", action="store_true", help="Write analysis details to the console during analysis") @@ -52,6 +54,20 @@ def configure_parser(): return parser +def get_float_argument_value(value): + """ + Convert an argument value to a float + + :param value: Argument value + :return: Floating point number derived from the value or None + """ + try: + return float(value[0]) + + except ValueError: + return None + + def parse_command_line(): """ Configure the command line parser and parse the command line @@ -86,6 +102,9 @@ def parse_command_line(): OPT_DOCX: args.docx[0] if args.docx else None, OPT_IMAGE: args.image[0] if args.image else None, OPT_HALFMOVES: int(args.halfmoves[0]) if args.halfmoves else None, + OPT_IMAGE: args.image[0] if args.image else None, + OPT_MOVIE: args.movie[0] if args.movie else None, + OPT_DURATION: get_float_argument_value(args.duration), # Flags OPT_VERBOSE: args.verbose @@ -134,7 +153,10 @@ def dispatch_export(options): export_pgn(options) if options[OPT_IMAGE]: - write_board_position_image(options[OPT_REFERENCE], options[OPT_HALFMOVES], options[OPT_IMAGE]) + export_board_image_after_halfmoves(options[OPT_REFERENCE], options[OPT_HALFMOVES], options[OPT_IMAGE]) + + if options[OPT_MOVIE]: + export_movie(options[OPT_REFERENCE], options[OPT_MOVIE], options[OPT_DURATION]) def confirm(targets): diff --git a/src/chess_analyser/constants/__init__.py b/src/chess_analyser/constants/__init__.py index 3a875b0..831bc3b 100644 --- a/src/chess_analyser/constants/__init__.py +++ b/src/chess_analyser/constants/__init__.py @@ -25,6 +25,8 @@ OPT_DOCX = "docx" OPT_IMAGE = "image" OPT_HALFMOVES = "halfmoves" +OPT_MOVIE = "movie" +OPT_DURATION = "duration" OPT_VERBOSE = "verbose" diff --git a/src/chess_analyser/reporting/__init__.py b/src/chess_analyser/reporting/__init__.py index b4d155b..edb7ca3 100644 --- a/src/chess_analyser/reporting/__init__.py +++ b/src/chess_analyser/reporting/__init__.py @@ -1,7 +1,8 @@ from .console_reports import print_analysis_table_headers, print_analysis_table_row, tabulate_analysis, tabulate_summary, \ tabulate_win_chance, tabulate_players, tabulate_game_info, tabulate_games from .document import write_analysis_document -from .images import write_board_position_image, write_win_percent_chart_image +from .images import export_current_position_image, export_board_image_after_halfmoves, export_win_percent_chart_image +from .movies import export_movie from .spreadsheet import write_analysis_spreadsheet from .game_info import load_game_information from .search import search_metadata @@ -17,9 +18,11 @@ "tabulate_game_info", "tabulate_games", "write_analysis_document", - "write_board_position_image", - "write_win_percent_chart_image", + "export_current_position_image", + "export_board_image_after_halfmoves", + "export_win_percent_chart_image", "write_analysis_spreadsheet", "load_game_information", - "search_metadata" + "search_metadata", + "export_movie" ] diff --git a/src/chess_analyser/reporting/document.py b/src/chess_analyser/reporting/document.py index e454e1c..e921469 100644 --- a/src/chess_analyser/reporting/document.py +++ b/src/chess_analyser/reporting/document.py @@ -3,7 +3,7 @@ EVALUATION_INDEX, CP_LOSS_INDEX, WIN_PERCENT_INDEX, ACCURACY_INDEX from .constants import ANALYSIS_HEADERS, SUMMARY_HEADERS from ..analysis.calculations import calculate_summary_statistics, extract_player_analysis -from .images import write_board_position_image, write_win_percent_chart_image +from .images import export_board_image_after_halfmoves, export_win_percent_chart_image from .game_info import load_game_information from docx import Document from docx.shared import Pt @@ -98,10 +98,10 @@ def write_analysis_document(options): summary_statistics = calculate_summary_statistics(analysis) # Generate an image of the final position - board_position_image = write_board_position_image(options[OPT_REFERENCE], "*", None) + board_position_image = export_board_image_after_halfmoves(options[OPT_REFERENCE], "*", None) # Generate an image of the Win Chance Chart - win_percent_image = write_win_percent_chart_image(analysis) + win_percent_image = export_win_percent_chart_image(analysis) # Create the document document = Document() diff --git a/src/chess_analyser/reporting/images.py b/src/chess_analyser/reporting/images.py index 3ff95f3..562a8f4 100644 --- a/src/chess_analyser/reporting/images.py +++ b/src/chess_analyser/reporting/images.py @@ -1,4 +1,5 @@ from cairosvg import svg2png +import chess import chess.svg from ..analysis.calculations import calculate_win_chance_chart_data from ..database.logic import load_game @@ -7,7 +8,7 @@ import pandas as pd -def _fast_forward_game(board, moves, halfmoves): +def fast_forward_game(board, moves, halfmoves): """ Fast forward a game to the point at which a number of halfmoves have been completed @@ -32,13 +33,28 @@ def _fast_forward_game(board, moves, halfmoves): return -def write_board_position_image(identifier, halfmoves, filename): +def export_current_position_image(board, filename): + """ + Export a PNG image of the current position of the specified board + + :param board: python-chess board object + :param filename: Optional filename to export to, or None + :return: Actual filename written + """ + image_file_path = filename if filename else f"board-position-{os.getpid()}.png" + svg_image = chess.svg.board(board=board) + svg2png(bytestring=svg_image, write_to=image_file_path) + return image_file_path + + +def export_board_image_after_halfmoves(identifier, halfmoves, filename): """ Generate a PNG image of the final state of the board for a game :param identifier: Game identifier :param halfmoves: Fast-forward by this number of halfmoves before exporting - :param fiename: Optional output filename + :param fiename: Optional filename to export to, or None + :return: Actual filename written """ # Load the game game = load_game(identifier) @@ -49,18 +65,16 @@ def write_board_position_image(identifier, halfmoves, filename): if not game.moves: raise ValueError(f"No moves found for the game with ID {game.id}") - # Fast forward to the specified point in the game. A value of -1 + # Fast forward to the specified point in the game board = chess.Board() _fast_forward_game(board, game.moves, halfmoves) # Now create an SVG image from the board in that position and convert to PNG - image_file_path = filename if filename else f"board-position-{os.getpid()}.png" - svg_image = chess.svg.board(board=board) - svg2png(bytestring=svg_image, write_to=image_file_path) + image_file_path = export_current_position_image(board, filename) return image_file_path -def write_win_percent_chart_image(analysis): +def export_win_percent_chart_image(analysis): """ Generate a PNG image containing the win percent chart diff --git a/src/chess_analyser/reporting/movies.py b/src/chess_analyser/reporting/movies.py new file mode 100644 index 0000000..d07e335 --- /dev/null +++ b/src/chess_analyser/reporting/movies.py @@ -0,0 +1,72 @@ +from moviepy import ImageClip, TextClip, CompositeVideoClip, concatenate_videoclips +from ..database.logic import load_game +from .images import export_current_position_image +import chess +import os +from pathlib import Path +import tempfile + +STARTING_POSITION_IMAGE = "start.png" + + +def _create_image_clip_from_position(board, folder, image_name, duration): + """ + Create an image clip from an image generated from the current board position + + :param board: python-chess board object + :param folder: Folder to write the image to + :param image_name: File name for the image + :param duration: Duration of the clip in seconds + """ + # Export the board position as an image + image_path = str(Path(folder) / Path(image_name)) + _ = export_current_position_image(board, image_path) + + # Create the image clip from the board position + image_clip = ImageClip(image_path).with_duration(duration) + + # Delete the image + Path(image_path).resolve().unlink() + return image_clip + + +def export_movie(identifier, movie_file, clip_duration): + """ + Write a movie of all the moves in a game + + :param identifier: Game identifier + :param movie_file: Output movie file path + :param clip_duration: Time in seconds between each move + """ + + # Load the game + game = load_game(identifier) + if not game: + raise ValueError(f"Game {identifier} not found") + + # Relationship/navigation properties should load the moves + if not game.moves: + raise ValueError(f"No moves found for the game with ID {game.id}") + + # Create a temporary folder to hold the images + folder = "/Users/dave/Temporary" # tempfile.TemporaryDirectory().name + + # Create a board and write the starting position image before any moves + board = chess.Board() + image_clip = _create_image_clip_from_position(board, folder, STARTING_POSITION_IMAGE, clip_duration) + clips = [image_clip] + + # Iterate over the moves, making each one, in turn, and capturing an image of the board + for i, move in enumerate(game.moves): + # Capture the SAN and push the UCI move + san = move.san + board.push_uci(move.uci) + + # Generate a board image clip + image_clip = _create_image_clip_from_position(board, folder, f"{i}.png", clip_duration) + clips.append(image_clip) + + # Combine all clips into the final video + fps = 1.0 / clip_duration + final_clip = concatenate_videoclips(clips, method="compose") + final_clip.write_videofile(movie_file, fps=fps, codec="libx264")