diff --git a/python_pkg/keyboard_coop/tests/conftest.py b/python_pkg/keyboard_coop/tests/conftest.py new file mode 100644 index 0000000..2960def --- /dev/null +++ b/python_pkg/keyboard_coop/tests/conftest.py @@ -0,0 +1,14 @@ +"""Shared fixtures for keyboard_coop tests.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + +import pytest + + +@pytest.fixture(autouse=True) +def mock_pygame() -> MagicMock: + """Mock pygame to prevent display initialization.""" + with patch.dict("sys.modules", {"pygame": MagicMock()}): + yield diff --git a/python_pkg/keyboard_coop/tests/test_constants.py b/python_pkg/keyboard_coop/tests/test_constants.py new file mode 100644 index 0000000..5dedb52 --- /dev/null +++ b/python_pkg/keyboard_coop/tests/test_constants.py @@ -0,0 +1,148 @@ +"""Tests for keyboard_coop constants and dataclasses.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + + +class TestConstants: + """Tests for module constants.""" + + def test_screen_dimensions(self) -> None: + """Test screen dimension constants.""" + with patch.dict("sys.modules", {"pygame": MagicMock()}): + from python_pkg.keyboard_coop.main import SCREEN_HEIGHT, SCREEN_WIDTH + + expected_width = 1366 + expected_height = 768 + assert expected_width == SCREEN_WIDTH + assert expected_height == SCREEN_HEIGHT + + def test_min_word_length(self) -> None: + """Test minimum word length constant.""" + with patch.dict("sys.modules", {"pygame": MagicMock()}): + from python_pkg.keyboard_coop.main import MIN_WORD_LENGTH + + expected_min = 3 + assert expected_min == MIN_WORD_LENGTH + + def test_keyboard_layout_structure(self) -> None: + """Test KEYBOARD_LAYOUT has correct structure.""" + with patch.dict("sys.modules", {"pygame": MagicMock()}): + from python_pkg.keyboard_coop.main import KEYBOARD_LAYOUT + + expected_rows = 3 + assert len(KEYBOARD_LAYOUT) == expected_rows + expected_first_row_len = 10 + expected_second_row_len = 9 + expected_third_row_len = 7 + assert len(KEYBOARD_LAYOUT[0]) == expected_first_row_len + assert len(KEYBOARD_LAYOUT[1]) == expected_second_row_len + assert len(KEYBOARD_LAYOUT[2]) == expected_third_row_len + + +class TestKeyAdjacency: + """Tests for KEY_ADJACENCY mapping.""" + + def test_q_adjacents(self) -> None: + """Test Q key has correct adjacent keys.""" + with patch.dict("sys.modules", {"pygame": MagicMock()}): + from python_pkg.keyboard_coop.main import KEY_ADJACENCY + + assert set(KEY_ADJACENCY["q"]) == {"w", "a", "s"} + + def test_all_letters_have_adjacents(self) -> None: + """Test all 26 letters have adjacency entries.""" + with patch.dict("sys.modules", {"pygame": MagicMock()}): + from python_pkg.keyboard_coop.main import KEY_ADJACENCY + + alphabet = "qwertyuiopasdfghjklzxcvbnm" + for letter in alphabet: + assert letter in KEY_ADJACENCY + assert len(KEY_ADJACENCY[letter]) > 0 + + +class TestGameState: + """Tests for GameState dataclass.""" + + def test_default_values(self) -> None: + """Test GameState default values.""" + with patch.dict("sys.modules", {"pygame": MagicMock()}): + from python_pkg.keyboard_coop.main import GameState + + state = GameState() + assert state.current_player == 0 + assert state.current_word == "" + assert state.selected_letters == [] + assert state.score == 0 + assert state.game_over is False + assert "Player 1" in state.message + + def test_custom_values(self) -> None: + """Test GameState with custom values.""" + with patch.dict("sys.modules", {"pygame": MagicMock()}): + from python_pkg.keyboard_coop.main import GameState + + state = GameState( + current_player=1, + current_word="test", + selected_letters=["t", "e", "s", "t"], + score=100, + game_over=True, + message="Game Over!", + ) + assert state.current_player == 1 + assert state.current_word == "test" + expected_score = 100 + assert state.score == expected_score + + +class TestKeyboardState: + """Tests for KeyboardState dataclass.""" + + def test_default_values(self) -> None: + """Test KeyboardState default values.""" + with patch.dict("sys.modules", {"pygame": MagicMock()}): + from python_pkg.keyboard_coop.main import KeyboardState + + kb_state = KeyboardState() + assert kb_state.layout == [] + assert kb_state.available_letters == set() + assert kb_state.adjacency == {} + assert kb_state.positions == {} + + +class TestFontSet: + """Tests for FontSet dataclass.""" + + def test_fontset_creation(self) -> None: + """Test FontSet stores fonts correctly.""" + mock_font = MagicMock() + with patch.dict("sys.modules", {"pygame": MagicMock()}): + from python_pkg.keyboard_coop.main import FontSet + + fonts = FontSet(normal=mock_font, large=mock_font, small=mock_font) + assert fonts.normal == mock_font + assert fonts.large == mock_font + assert fonts.small == mock_font + + +class TestColors: + """Tests for color constants.""" + + def test_background_color_is_rgb_tuple(self) -> None: + """Test BACKGROUND_COLOR is an RGB tuple.""" + with patch.dict("sys.modules", {"pygame": MagicMock()}): + from python_pkg.keyboard_coop.main import BACKGROUND_COLOR + + expected_len = 3 + assert len(BACKGROUND_COLOR) == expected_len + assert all(isinstance(c, int) for c in BACKGROUND_COLOR) + + def test_player_colors_list(self) -> None: + """Test PLAYER_COLORS has colors for 2 players.""" + with patch.dict("sys.modules", {"pygame": MagicMock()}): + from python_pkg.keyboard_coop.main import PLAYER_COLORS + + expected_players = 2 + assert len(PLAYER_COLORS) == expected_players diff --git a/python_pkg/keyboard_coop/tests/test_game_logic.py b/python_pkg/keyboard_coop/tests/test_game_logic.py new file mode 100644 index 0000000..22326e3 --- /dev/null +++ b/python_pkg/keyboard_coop/tests/test_game_logic.py @@ -0,0 +1,371 @@ +"""Tests for keyboard_coop game logic methods.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING +from unittest.mock import MagicMock, patch + +import pytest + +if TYPE_CHECKING: + from python_pkg.keyboard_coop.main import KeyboardCoopGame + + +class TestKeyboardCoopGame: + """Tests for KeyboardCoopGame class methods.""" + + @pytest.fixture + def mock_game(self) -> KeyboardCoopGame: + """Create a mock game instance without pygame initialization.""" + mock_pg = MagicMock() + mock_pg.font.Font.return_value = MagicMock() + mock_pg.Rect = MagicMock() + + with patch.dict("sys.modules", {"pygame": mock_pg}): + from python_pkg.keyboard_coop.main import ( + GameState, + KeyboardCoopGame, + KeyboardState, + ) + + # Create game without calling __init__ directly + game = object.__new__(KeyboardCoopGame) + game.state = GameState() + game.keyboard = KeyboardState() + game.keyboard.layout = [["a", "b", "c"], ["d", "e", "f"]] + game.keyboard.adjacency = { + "a": ["b", "d"], + "b": ["a", "c", "e"], + "c": ["b", "f"], + "d": ["a", "e"], + "e": ["b", "d", "f"], + "f": ["c", "e"], + } + game.keyboard.available_letters = {"a", "b", "c", "d", "e", "f"} + game.dictionary = {"cat", "bat", "cab", "bad", "bed", "fed", "fad", "ace"} + return game + + def test_is_valid_move_first_letter(self, mock_game: KeyboardCoopGame) -> None: + """Test first letter is always valid.""" + mock_game.state.selected_letters = [] + assert mock_game._is_valid_move("a") is True + assert mock_game._is_valid_move("z") is True + + def test_is_valid_move_adjacent(self, mock_game: KeyboardCoopGame) -> None: + """Test adjacent letter is valid.""" + mock_game.state.selected_letters = ["a"] + # "b" and "d" are adjacent to "a" + assert mock_game._is_valid_move("b") is True + assert mock_game._is_valid_move("d") is True + + def test_is_valid_move_not_adjacent(self, mock_game: KeyboardCoopGame) -> None: + """Test non-adjacent letter is invalid.""" + mock_game.state.selected_letters = ["a"] + # "f" is not adjacent to "a" + assert mock_game._is_valid_move("f") is False + + def test_is_valid_word_true(self, mock_game: KeyboardCoopGame) -> None: + """Test valid word returns True.""" + assert mock_game._is_valid_word("cat") is True + assert mock_game._is_valid_word("CAT") is True # Case insensitive + + def test_is_valid_word_false(self, mock_game: KeyboardCoopGame) -> None: + """Test invalid word returns False.""" + assert mock_game._is_valid_word("xyz") is False + + def test_calculate_score_min_length(self, mock_game: KeyboardCoopGame) -> None: + """Test score calculation for minimum length word.""" + # 3-letter word: 2^(3-2) = 2 + assert mock_game._calculate_score(3) == 2 + + def test_calculate_score_longer_word(self, mock_game: KeyboardCoopGame) -> None: + """Test score calculation for longer words.""" + # 4-letter: 2^(4-2) = 4 + assert mock_game._calculate_score(4) == 4 + # 5-letter: 2^(5-2) = 8 + assert mock_game._calculate_score(5) == 8 + + def test_calculate_score_too_short(self, mock_game: KeyboardCoopGame) -> None: + """Test score for words below minimum length is 0.""" + assert mock_game._calculate_score(2) == 0 + assert mock_game._calculate_score(1) == 0 + + def test_handle_letter_click_valid(self, mock_game: KeyboardCoopGame) -> None: + """Test clicking a valid letter adds it to word.""" + mock_game.state.selected_letters = [] + mock_game.state.current_word = "" + mock_game.state.current_player = 0 + + mock_game._handle_letter_click("a") + + assert mock_game.state.selected_letters == ["a"] + assert mock_game.state.current_word == "a" + assert mock_game.state.current_player == 1 # Switched + + def test_handle_letter_click_invalid_not_available( + self, mock_game: KeyboardCoopGame + ) -> None: + """Test clicking unavailable letter does nothing.""" + mock_game.keyboard.available_letters = {"b", "c"} + mock_game.state.selected_letters = [] + mock_game.state.current_word = "" + + mock_game._handle_letter_click("a") + + assert mock_game.state.selected_letters == [] + assert mock_game.state.current_word == "" + + def test_submit_word_valid(self, mock_game: KeyboardCoopGame) -> None: + """Test submitting a valid word adds score.""" + mock_game._generate_random_keyboard = MagicMock() + mock_game.state.current_word = "cat" + mock_game.state.selected_letters = ["c", "a", "t"] + mock_game.state.score = 0 + + mock_game._submit_word() + + assert mock_game.state.score == 2 # 2^(3-2) = 2 + assert mock_game.state.current_word == "" + assert mock_game.state.selected_letters == [] + + def test_submit_word_too_short(self, mock_game: KeyboardCoopGame) -> None: + """Test submitting too short word gives no score.""" + mock_game.state.current_word = "ca" + mock_game.state.selected_letters = ["c", "a"] + mock_game.state.score = 0 + + mock_game._submit_word() + + assert mock_game.state.score == 0 + assert "too short" in mock_game.state.message + + def test_submit_word_invalid(self, mock_game: KeyboardCoopGame) -> None: + """Test submitting invalid word gives no score.""" + mock_game.state.current_word = "xyz" + mock_game.state.selected_letters = ["x", "y", "z"] + mock_game.state.score = 0 + + mock_game._submit_word() + + assert mock_game.state.score == 0 + assert "not a valid word" in mock_game.state.message + + def test_reset_game(self, mock_game: KeyboardCoopGame) -> None: + """Test reset_game creates new state.""" + mock_game._generate_random_keyboard = MagicMock() + mock_game.state.score = 100 + mock_game.state.current_word = "test" + + mock_game._reset_game() + + # After reset, state should be fresh + assert mock_game.state.score == 0 + assert mock_game.state.current_word == "" + assert mock_game._generate_random_keyboard.called + + def test_get_key_at_position_found(self, mock_game: KeyboardCoopGame) -> None: + """Test getting key at position when key exists.""" + mock_rect = MagicMock() + mock_rect.collidepoint.return_value = True + mock_game.keyboard.positions = {"a": mock_rect} + + result = mock_game._get_key_at_position((100, 100)) + assert result == "a" + + def test_get_key_at_position_not_found(self, mock_game: KeyboardCoopGame) -> None: + """Test getting key at position when no key.""" + mock_rect = MagicMock() + mock_rect.collidepoint.return_value = False + mock_game.keyboard.positions = {"a": mock_rect} + + result = mock_game._get_key_at_position((100, 100)) + assert result is None + + +class TestLoadDictionary: + """Tests for dictionary loading.""" + + def test_fallback_dictionary_used(self) -> None: + """Test fallback dictionary when file not found.""" + mock_pg = MagicMock() + mock_pg.font.Font.return_value = MagicMock() + mock_pg.display.set_mode.return_value = MagicMock() + + with ( + patch.dict("sys.modules", {"pygame": mock_pg}), + patch("pathlib.Path.open", side_effect=FileNotFoundError), + ): + from python_pkg.keyboard_coop.main import KeyboardCoopGame + + game = object.__new__(KeyboardCoopGame) + dictionary = game._load_dictionary() + + # Should have fallback words + assert "cat" in dictionary + assert "dog" in dictionary + + def test_json_decode_error_fallback(self) -> None: + """Test fallback dictionary when JSON is invalid.""" + import json + + mock_pg = MagicMock() + mock_pg.font.Font.return_value = MagicMock() + mock_pg.display.set_mode.return_value = MagicMock() + + mock_file = MagicMock() + mock_file.__enter__ = MagicMock(return_value=mock_file) + mock_file.__exit__ = MagicMock(return_value=False) + + with ( + patch.dict("sys.modules", {"pygame": mock_pg}), + patch("pathlib.Path.open", return_value=mock_file), + patch("json.load", side_effect=json.JSONDecodeError("err", "doc", 0)), + ): + from python_pkg.keyboard_coop.main import KeyboardCoopGame + + game = object.__new__(KeyboardCoopGame) + dictionary = game._load_dictionary() + + # Should have fallback words from JSONDecodeError handler + assert "cat" in dictionary + assert "dog" in dictionary + + +class TestGenerateRandomKeyboard: + """Tests for keyboard layout generation.""" + + def test_generate_random_keyboard_creates_26_letters(self) -> None: + """Test keyboard generation includes all 26 letters.""" + mock_pg = MagicMock() + mock_pg.Rect = MagicMock(return_value=MagicMock()) + + with patch.dict("sys.modules", {"pygame": mock_pg}): + from python_pkg.keyboard_coop.main import ( + KeyboardCoopGame, + KeyboardState, + ) + + game = object.__new__(KeyboardCoopGame) + game.keyboard = KeyboardState() + + game._generate_random_keyboard() + + # Should have 26 letters total across all rows + all_letters = [] + for row in game.keyboard.layout: + all_letters.extend(row) + assert len(all_letters) == 26 + assert len(set(all_letters)) == 26 # All unique + + def test_layout_structure_is_10_9_7(self) -> None: + """Test keyboard layout has correct row structure.""" + mock_pg = MagicMock() + mock_pg.Rect = MagicMock(return_value=MagicMock()) + + with patch.dict("sys.modules", {"pygame": mock_pg}): + from python_pkg.keyboard_coop.main import ( + KeyboardCoopGame, + KeyboardState, + ) + + game = object.__new__(KeyboardCoopGame) + game.keyboard = KeyboardState() + + game._generate_random_keyboard() + + assert len(game.keyboard.layout) == 3 + assert len(game.keyboard.layout[0]) == 10 + assert len(game.keyboard.layout[1]) == 9 + assert len(game.keyboard.layout[2]) == 7 + + +class TestCalculateAdjacencies: + """Tests for adjacency calculation.""" + + def test_calculate_adjacencies_populates_all_letters(self) -> None: + """Test adjacency calculation includes all letters.""" + mock_pg = MagicMock() + + with patch.dict("sys.modules", {"pygame": mock_pg}): + from python_pkg.keyboard_coop.main import ( + KeyboardCoopGame, + KeyboardState, + ) + + game = object.__new__(KeyboardCoopGame) + game.keyboard = KeyboardState() + game.keyboard.layout = [ + ["a", "b", "c"], + ["d", "e", "f"], + ["g", "h"], + ] + + game._calculate_adjacencies() + + # Each letter should have adjacency list + assert len(game.keyboard.adjacency) == 8 + # Corner letter should have fewer adjacents + assert "b" in game.keyboard.adjacency["a"] + assert "d" in game.keyboard.adjacency["a"] + assert "e" in game.keyboard.adjacency["a"] + + +class TestCalculateKeyPositions: + """Tests for key position calculation.""" + + def test_calculate_key_positions_creates_rects(self) -> None: + """Test key position calculation creates rect for each key.""" + mock_pg = MagicMock() + mock_pg.Rect = MagicMock(return_value=MagicMock()) + + with patch.dict("sys.modules", {"pygame": mock_pg}): + from python_pkg.keyboard_coop.main import ( + KeyboardCoopGame, + KeyboardState, + ) + + game = object.__new__(KeyboardCoopGame) + game.keyboard = KeyboardState() + game.keyboard.layout = [["a", "b"], ["c", "d"]] + + positions = game._calculate_key_positions() + + assert len(positions) == 4 + assert "a" in positions + assert "d" in positions + + +class TestGameInit: + """Tests for game initialization.""" + + def test_init_creates_all_components(self) -> None: + """Test __init__ properly initializes all game components.""" + mock_pg = MagicMock() + mock_pg.font.Font.return_value = MagicMock() + mock_pg.display.set_mode.return_value = MagicMock() + mock_pg.Rect = MagicMock(return_value=MagicMock()) + + mock_file = MagicMock() + mock_file.__enter__ = MagicMock(return_value=mock_file) + mock_file.__exit__ = MagicMock(return_value=False) + + with ( + patch.dict("sys.modules", {"pygame": mock_pg}), + patch("pathlib.Path.open", return_value=mock_file), + patch("json.load", return_value={"cat": 1, "dog": 1}), + ): + from python_pkg.keyboard_coop.main import KeyboardCoopGame + + game = KeyboardCoopGame() + + # Verify pygame display was set up + mock_pg.display.set_mode.assert_called() + mock_pg.display.set_caption.assert_called_with("Keyboard Coop Game") + + # Verify game components are initialized + assert game.screen is not None + assert game.clock is not None + assert game.fonts is not None + assert game.dictionary is not None + assert game.state is not None + assert game.keyboard is not None diff --git a/python_pkg/keyboard_coop/tests/test_game_loop.py b/python_pkg/keyboard_coop/tests/test_game_loop.py new file mode 100644 index 0000000..8d0d522 --- /dev/null +++ b/python_pkg/keyboard_coop/tests/test_game_loop.py @@ -0,0 +1,426 @@ +"""Tests for keyboard_coop game loop and forced submission.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + + +class TestForceSubmitWhenNoMoves: + """Tests for forced word submission when no moves available.""" + + def test_submit_called_when_available_letters_empty(self) -> None: + """Test that word is submitted when no valid moves remain. + + This tests the defensive code path at line 351 where _submit_word + is called if available_letters becomes empty after a letter click. + """ + mock_pg = MagicMock() + + with patch.dict("sys.modules", {"pygame": mock_pg}): + from python_pkg.keyboard_coop.main import ( + GameState, + KeyboardCoopGame, + KeyboardState, + ) + + game = object.__new__(KeyboardCoopGame) + game.state = GameState() + game.keyboard = KeyboardState() + game.keyboard.layout = [["a", "b"]] + game.keyboard.adjacency = {} + game.keyboard.available_letters = {"a"} + game.keyboard.positions = {} + game.dictionary = {"a": 1} + + # Simulate scenario where available_letters becomes empty + # This is defensive code that's hard to trigger naturally + game._submit_word = MagicMock() + + def patched_handle(letter: str) -> None: + """Patched handler that clears available letters.""" + if letter in game.keyboard.available_letters: + game.state.selected_letters.append(letter) + game.state.current_word += letter + # Force empty to trigger the check + game.keyboard.available_letters = set() + if not game.keyboard.available_letters: + game._submit_word() + + patched_handle("a") + + # Should have triggered submit_word + game._submit_word.assert_called() + + +class TestGameLoop: + """Tests for the main game loop.""" + + def test_run_quit_event(self) -> None: + """Test game loop exits on QUIT event.""" + mock_pg = MagicMock() + + # Create quit event + quit_event = MagicMock() + quit_event.type = "QUIT" + mock_pg.QUIT = "QUIT" + mock_pg.MOUSEBUTTONDOWN = "MOUSEDOWN" + mock_pg.KEYDOWN = "KEYDOWN" + mock_pg.event.get.return_value = [quit_event] + + with ( + patch.dict("sys.modules", {"pygame": mock_pg}), + patch("sys.exit") as mock_exit, + ): + from python_pkg.keyboard_coop.main import ( + FontSet, + GameState, + KeyboardCoopGame, + KeyboardState, + ) + + game = object.__new__(KeyboardCoopGame) + game.screen = MagicMock() + game.clock = MagicMock() + game.state = GameState() + game.keyboard = KeyboardState() + game.keyboard.positions = {} + game.fonts = FontSet( + normal=MagicMock(), large=MagicMock(), small=MagicMock() + ) + game._draw_keyboard = MagicMock() + game._draw_ui = MagicMock(return_value=(MagicMock(), MagicMock())) + + game.run() + + mock_pg.quit.assert_called() + mock_exit.assert_called() + + def test_run_mouse_click_event(self) -> None: + """Test game loop handles mouse click event.""" + mock_pg = MagicMock() + + # Create mouse click event followed by quit + click_event = MagicMock() + click_event.type = "MOUSEDOWN" + click_event.button = 1 + click_event.pos = (100, 100) + + quit_event = MagicMock() + quit_event.type = "QUIT" + + mock_pg.QUIT = "QUIT" + mock_pg.MOUSEBUTTONDOWN = "MOUSEDOWN" + mock_pg.KEYDOWN = "KEYDOWN" + # Return click event first, then quit event + mock_pg.event.get.side_effect = [[click_event], [quit_event]] + + with ( + patch.dict("sys.modules", {"pygame": mock_pg}), + patch("sys.exit"), + ): + from python_pkg.keyboard_coop.main import ( + FontSet, + GameState, + KeyboardCoopGame, + KeyboardState, + ) + + game = object.__new__(KeyboardCoopGame) + game.screen = MagicMock() + game.clock = MagicMock() + game.state = GameState() + game.keyboard = KeyboardState() + game.keyboard.positions = {} + game.fonts = FontSet( + normal=MagicMock(), large=MagicMock(), small=MagicMock() + ) + game._draw_keyboard = MagicMock() + game._draw_ui = MagicMock(return_value=(MagicMock(), MagicMock())) + game._handle_click = MagicMock() + + game.run() + + game._handle_click.assert_called_with((100, 100)) + + def test_run_enter_key_event(self) -> None: + """Test game loop handles ENTER key event.""" + mock_pg = MagicMock() + + key_event = MagicMock() + key_event.type = "KEYDOWN" + key_event.key = "K_RETURN" + + quit_event = MagicMock() + quit_event.type = "QUIT" + + mock_pg.QUIT = "QUIT" + mock_pg.MOUSEBUTTONDOWN = "MOUSEDOWN" + mock_pg.KEYDOWN = "KEYDOWN" + mock_pg.K_RETURN = "K_RETURN" + mock_pg.K_r = "K_r" + mock_pg.event.get.side_effect = [[key_event], [quit_event]] + + with ( + patch.dict("sys.modules", {"pygame": mock_pg}), + patch("sys.exit"), + ): + from python_pkg.keyboard_coop.main import ( + FontSet, + GameState, + KeyboardCoopGame, + KeyboardState, + ) + + game = object.__new__(KeyboardCoopGame) + game.screen = MagicMock() + game.clock = MagicMock() + game.state = GameState() + game.keyboard = KeyboardState() + game.keyboard.positions = {} + game.fonts = FontSet( + normal=MagicMock(), large=MagicMock(), small=MagicMock() + ) + game._draw_keyboard = MagicMock() + game._draw_ui = MagicMock(return_value=(MagicMock(), MagicMock())) + game._submit_word = MagicMock() + + game.run() + + game._submit_word.assert_called() + + def test_run_r_key_reset(self) -> None: + """Test game loop handles R key for reset.""" + mock_pg = MagicMock() + + key_event = MagicMock() + key_event.type = "KEYDOWN" + key_event.key = "K_r" + + quit_event = MagicMock() + quit_event.type = "QUIT" + + mock_pg.QUIT = "QUIT" + mock_pg.MOUSEBUTTONDOWN = "MOUSEDOWN" + mock_pg.KEYDOWN = "KEYDOWN" + mock_pg.K_RETURN = "K_RETURN" + mock_pg.K_r = "K_r" + mock_pg.event.get.side_effect = [[key_event], [quit_event]] + + with ( + patch.dict("sys.modules", {"pygame": mock_pg}), + patch("sys.exit"), + ): + from python_pkg.keyboard_coop.main import ( + FontSet, + GameState, + KeyboardCoopGame, + KeyboardState, + ) + + game = object.__new__(KeyboardCoopGame) + game.screen = MagicMock() + game.clock = MagicMock() + game.state = GameState() + game.keyboard = KeyboardState() + game.keyboard.positions = {} + game.fonts = FontSet( + normal=MagicMock(), large=MagicMock(), small=MagicMock() + ) + game._draw_keyboard = MagicMock() + game._draw_ui = MagicMock(return_value=(MagicMock(), MagicMock())) + game._reset_game = MagicMock() + + game.run() + + game._reset_game.assert_called() + + def test_run_letter_key_press(self) -> None: + """Test game loop handles letter key presses.""" + mock_pg = MagicMock() + + key_event = MagicMock() + key_event.type = "KEYDOWN" + key_event.key = "some_key" + + quit_event = MagicMock() + quit_event.type = "QUIT" + + mock_pg.QUIT = "QUIT" + mock_pg.MOUSEBUTTONDOWN = "MOUSEDOWN" + mock_pg.KEYDOWN = "KEYDOWN" + mock_pg.K_RETURN = "K_RETURN" + mock_pg.K_r = "K_r" + mock_pg.key.name.return_value = "a" # Single letter key + mock_pg.event.get.side_effect = [[key_event], [quit_event]] + + with ( + patch.dict("sys.modules", {"pygame": mock_pg}), + patch("sys.exit"), + ): + from python_pkg.keyboard_coop.main import ( + FontSet, + GameState, + KeyboardCoopGame, + KeyboardState, + ) + + game = object.__new__(KeyboardCoopGame) + game.screen = MagicMock() + game.clock = MagicMock() + game.state = GameState() + game.keyboard = KeyboardState() + game.keyboard.positions = {} + game.fonts = FontSet( + normal=MagicMock(), large=MagicMock(), small=MagicMock() + ) + game._draw_keyboard = MagicMock() + game._draw_ui = MagicMock(return_value=(MagicMock(), MagicMock())) + game._handle_letter_click = MagicMock() + + game.run() + + game._handle_letter_click.assert_called_with("a") + + def test_run_right_click_ignored(self) -> None: + """Test game loop ignores non-left mouse clicks.""" + mock_pg = MagicMock() + + click_event = MagicMock() + click_event.type = "MOUSEDOWN" + click_event.button = 3 # Right click + click_event.pos = (100, 100) + + quit_event = MagicMock() + quit_event.type = "QUIT" + + mock_pg.QUIT = "QUIT" + mock_pg.MOUSEBUTTONDOWN = "MOUSEDOWN" + mock_pg.KEYDOWN = "KEYDOWN" + mock_pg.event.get.side_effect = [[click_event], [quit_event]] + + with ( + patch.dict("sys.modules", {"pygame": mock_pg}), + patch("sys.exit"), + ): + from python_pkg.keyboard_coop.main import ( + FontSet, + GameState, + KeyboardCoopGame, + KeyboardState, + ) + + game = object.__new__(KeyboardCoopGame) + game.screen = MagicMock() + game.clock = MagicMock() + game.state = GameState() + game.keyboard = KeyboardState() + game.keyboard.positions = {} + game.fonts = FontSet( + normal=MagicMock(), large=MagicMock(), small=MagicMock() + ) + game._draw_keyboard = MagicMock() + game._draw_ui = MagicMock(return_value=(MagicMock(), MagicMock())) + game._handle_click = MagicMock() + + game.run() + + # handle_click should NOT be called for right click + game._handle_click.assert_not_called() + + def test_run_special_key_ignored(self) -> None: + """Test game loop ignores non-letter key presses.""" + mock_pg = MagicMock() + + key_event = MagicMock() + key_event.type = "KEYDOWN" + key_event.key = "some_key" + + quit_event = MagicMock() + quit_event.type = "QUIT" + + mock_pg.QUIT = "QUIT" + mock_pg.MOUSEBUTTONDOWN = "MOUSEDOWN" + mock_pg.KEYDOWN = "KEYDOWN" + mock_pg.K_RETURN = "K_RETURN" + mock_pg.K_r = "K_r" + mock_pg.key.name.return_value = "escape" # Multi-char, not a letter + mock_pg.event.get.side_effect = [[key_event], [quit_event]] + + with ( + patch.dict("sys.modules", {"pygame": mock_pg}), + patch("sys.exit"), + ): + from python_pkg.keyboard_coop.main import ( + FontSet, + GameState, + KeyboardCoopGame, + KeyboardState, + ) + + game = object.__new__(KeyboardCoopGame) + game.screen = MagicMock() + game.clock = MagicMock() + game.state = GameState() + game.keyboard = KeyboardState() + game.keyboard.positions = {} + game.fonts = FontSet( + normal=MagicMock(), large=MagicMock(), small=MagicMock() + ) + game._draw_keyboard = MagicMock() + game._draw_ui = MagicMock(return_value=(MagicMock(), MagicMock())) + game._handle_letter_click = MagicMock() + + game.run() + + # handle_letter_click should NOT be called for special keys + game._handle_letter_click.assert_not_called() + + def test_run_unknown_event_type(self) -> None: + """Test game loop ignores unknown event types.""" + mock_pg = MagicMock() + + unknown_event = MagicMock() + unknown_event.type = "UNKNOWN" + + quit_event = MagicMock() + quit_event.type = "QUIT" + + mock_pg.QUIT = "QUIT" + mock_pg.MOUSEBUTTONDOWN = "MOUSEDOWN" + mock_pg.KEYDOWN = "KEYDOWN" + mock_pg.event.get.side_effect = [[unknown_event], [quit_event]] + + with ( + patch.dict("sys.modules", {"pygame": mock_pg}), + patch("sys.exit"), + ): + from python_pkg.keyboard_coop.main import ( + FontSet, + GameState, + KeyboardCoopGame, + KeyboardState, + ) + + game = object.__new__(KeyboardCoopGame) + game.screen = MagicMock() + game.clock = MagicMock() + game.state = GameState() + game.keyboard = KeyboardState() + game.keyboard.positions = {} + game.fonts = FontSet( + normal=MagicMock(), large=MagicMock(), small=MagicMock() + ) + game._draw_keyboard = MagicMock() + game._draw_ui = MagicMock(return_value=(MagicMock(), MagicMock())) + game._handle_click = MagicMock() + game._submit_word = MagicMock() + game._reset_game = MagicMock() + game._handle_letter_click = MagicMock() + + game.run() + + # None of the handlers should be called for unknown event + game._handle_click.assert_not_called() + game._submit_word.assert_not_called() + game._reset_game.assert_not_called() + game._handle_letter_click.assert_not_called() diff --git a/python_pkg/keyboard_coop/tests/test_main.py b/python_pkg/keyboard_coop/tests/test_main.py deleted file mode 100644 index 7710e32..0000000 --- a/python_pkg/keyboard_coop/tests/test_main.py +++ /dev/null @@ -1,1247 +0,0 @@ -"""Unit tests for keyboard_coop module.""" - -from typing import TYPE_CHECKING -from unittest.mock import MagicMock, patch - -import pytest - -if TYPE_CHECKING: - from python_pkg.keyboard_coop.main import KeyboardCoopGame - - -# Need to mock pygame before importing the module -@pytest.fixture(autouse=True) -def mock_pygame() -> MagicMock: - """Mock pygame to prevent display initialization.""" - with patch.dict("sys.modules", {"pygame": MagicMock()}): - yield - - -class TestConstants: - """Tests for module constants.""" - - def test_screen_dimensions(self) -> None: - """Test screen dimension constants.""" - with patch.dict("sys.modules", {"pygame": MagicMock()}): - from python_pkg.keyboard_coop.main import SCREEN_HEIGHT, SCREEN_WIDTH - - expected_width = 1366 - expected_height = 768 - assert expected_width == SCREEN_WIDTH - assert expected_height == SCREEN_HEIGHT - - def test_min_word_length(self) -> None: - """Test minimum word length constant.""" - with patch.dict("sys.modules", {"pygame": MagicMock()}): - from python_pkg.keyboard_coop.main import MIN_WORD_LENGTH - - expected_min = 3 - assert expected_min == MIN_WORD_LENGTH - - def test_keyboard_layout_structure(self) -> None: - """Test KEYBOARD_LAYOUT has correct structure.""" - with patch.dict("sys.modules", {"pygame": MagicMock()}): - from python_pkg.keyboard_coop.main import KEYBOARD_LAYOUT - - expected_rows = 3 - assert len(KEYBOARD_LAYOUT) == expected_rows - expected_first_row_len = 10 - expected_second_row_len = 9 - expected_third_row_len = 7 - assert len(KEYBOARD_LAYOUT[0]) == expected_first_row_len - assert len(KEYBOARD_LAYOUT[1]) == expected_second_row_len - assert len(KEYBOARD_LAYOUT[2]) == expected_third_row_len - - -class TestKeyAdjacency: - """Tests for KEY_ADJACENCY mapping.""" - - def test_q_adjacents(self) -> None: - """Test Q key has correct adjacent keys.""" - with patch.dict("sys.modules", {"pygame": MagicMock()}): - from python_pkg.keyboard_coop.main import KEY_ADJACENCY - - assert set(KEY_ADJACENCY["q"]) == {"w", "a", "s"} - - def test_all_letters_have_adjacents(self) -> None: - """Test all 26 letters have adjacency entries.""" - with patch.dict("sys.modules", {"pygame": MagicMock()}): - from python_pkg.keyboard_coop.main import KEY_ADJACENCY - - alphabet = "qwertyuiopasdfghjklzxcvbnm" - for letter in alphabet: - assert letter in KEY_ADJACENCY - assert len(KEY_ADJACENCY[letter]) > 0 - - -class TestGameState: - """Tests for GameState dataclass.""" - - def test_default_values(self) -> None: - """Test GameState default values.""" - with patch.dict("sys.modules", {"pygame": MagicMock()}): - from python_pkg.keyboard_coop.main import GameState - - state = GameState() - assert state.current_player == 0 - assert state.current_word == "" - assert state.selected_letters == [] - assert state.score == 0 - assert state.game_over is False - assert "Player 1" in state.message - - def test_custom_values(self) -> None: - """Test GameState with custom values.""" - with patch.dict("sys.modules", {"pygame": MagicMock()}): - from python_pkg.keyboard_coop.main import GameState - - state = GameState( - current_player=1, - current_word="test", - selected_letters=["t", "e", "s", "t"], - score=100, - game_over=True, - message="Game Over!", - ) - assert state.current_player == 1 - assert state.current_word == "test" - expected_score = 100 - assert state.score == expected_score - - -class TestKeyboardState: - """Tests for KeyboardState dataclass.""" - - def test_default_values(self) -> None: - """Test KeyboardState default values.""" - with patch.dict("sys.modules", {"pygame": MagicMock()}): - from python_pkg.keyboard_coop.main import KeyboardState - - kb_state = KeyboardState() - assert kb_state.layout == [] - assert kb_state.available_letters == set() - assert kb_state.adjacency == {} - assert kb_state.positions == {} - - -class TestFontSet: - """Tests for FontSet dataclass.""" - - def test_fontset_creation(self) -> None: - """Test FontSet stores fonts correctly.""" - mock_font = MagicMock() - with patch.dict("sys.modules", {"pygame": MagicMock()}): - from python_pkg.keyboard_coop.main import FontSet - - fonts = FontSet(normal=mock_font, large=mock_font, small=mock_font) - assert fonts.normal == mock_font - assert fonts.large == mock_font - assert fonts.small == mock_font - - -class TestColors: - """Tests for color constants.""" - - def test_background_color_is_rgb_tuple(self) -> None: - """Test BACKGROUND_COLOR is an RGB tuple.""" - with patch.dict("sys.modules", {"pygame": MagicMock()}): - from python_pkg.keyboard_coop.main import BACKGROUND_COLOR - - expected_len = 3 - assert len(BACKGROUND_COLOR) == expected_len - assert all(isinstance(c, int) for c in BACKGROUND_COLOR) - - def test_player_colors_list(self) -> None: - """Test PLAYER_COLORS has colors for 2 players.""" - with patch.dict("sys.modules", {"pygame": MagicMock()}): - from python_pkg.keyboard_coop.main import PLAYER_COLORS - - expected_players = 2 - assert len(PLAYER_COLORS) == expected_players - - -class TestKeyboardCoopGame: - """Tests for KeyboardCoopGame class methods.""" - - @pytest.fixture - def mock_game(self) -> "KeyboardCoopGame": - """Create a mock game instance without pygame initialization.""" - mock_pg = MagicMock() - mock_pg.font.Font.return_value = MagicMock() - mock_pg.Rect = MagicMock() - - with patch.dict("sys.modules", {"pygame": mock_pg}): - from python_pkg.keyboard_coop.main import ( - GameState, - KeyboardCoopGame, - KeyboardState, - ) - - # Create game without calling __init__ directly - game = object.__new__(KeyboardCoopGame) - game.state = GameState() - game.keyboard = KeyboardState() - game.keyboard.layout = [["a", "b", "c"], ["d", "e", "f"]] - game.keyboard.adjacency = { - "a": ["b", "d"], - "b": ["a", "c", "e"], - "c": ["b", "f"], - "d": ["a", "e"], - "e": ["b", "d", "f"], - "f": ["c", "e"], - } - game.keyboard.available_letters = {"a", "b", "c", "d", "e", "f"} - game.dictionary = {"cat", "bat", "cab", "bad", "bed", "fed", "fad", "ace"} - return game - - def test_is_valid_move_first_letter(self, mock_game: "KeyboardCoopGame") -> None: - """Test first letter is always valid.""" - mock_game.state.selected_letters = [] - assert mock_game._is_valid_move("a") is True - assert mock_game._is_valid_move("z") is True - - def test_is_valid_move_adjacent(self, mock_game: "KeyboardCoopGame") -> None: - """Test adjacent letter is valid.""" - mock_game.state.selected_letters = ["a"] - # "b" and "d" are adjacent to "a" - assert mock_game._is_valid_move("b") is True - assert mock_game._is_valid_move("d") is True - - def test_is_valid_move_not_adjacent(self, mock_game: "KeyboardCoopGame") -> None: - """Test non-adjacent letter is invalid.""" - mock_game.state.selected_letters = ["a"] - # "f" is not adjacent to "a" - assert mock_game._is_valid_move("f") is False - - def test_is_valid_word_true(self, mock_game: "KeyboardCoopGame") -> None: - """Test valid word returns True.""" - assert mock_game._is_valid_word("cat") is True - assert mock_game._is_valid_word("CAT") is True # Case insensitive - - def test_is_valid_word_false(self, mock_game: "KeyboardCoopGame") -> None: - """Test invalid word returns False.""" - assert mock_game._is_valid_word("xyz") is False - - def test_calculate_score_min_length(self, mock_game: "KeyboardCoopGame") -> None: - """Test score calculation for minimum length word.""" - # 3-letter word: 2^(3-2) = 2 - assert mock_game._calculate_score(3) == 2 - - def test_calculate_score_longer_word(self, mock_game: "KeyboardCoopGame") -> None: - """Test score calculation for longer words.""" - # 4-letter: 2^(4-2) = 4 - assert mock_game._calculate_score(4) == 4 - # 5-letter: 2^(5-2) = 8 - assert mock_game._calculate_score(5) == 8 - - def test_calculate_score_too_short(self, mock_game: "KeyboardCoopGame") -> None: - """Test score for words below minimum length is 0.""" - assert mock_game._calculate_score(2) == 0 - assert mock_game._calculate_score(1) == 0 - - def test_handle_letter_click_valid(self, mock_game: "KeyboardCoopGame") -> None: - """Test clicking a valid letter adds it to word.""" - mock_game.state.selected_letters = [] - mock_game.state.current_word = "" - mock_game.state.current_player = 0 - - mock_game._handle_letter_click("a") - - assert mock_game.state.selected_letters == ["a"] - assert mock_game.state.current_word == "a" - assert mock_game.state.current_player == 1 # Switched - - def test_handle_letter_click_invalid_not_available( - self, mock_game: "KeyboardCoopGame" - ) -> None: - """Test clicking unavailable letter does nothing.""" - mock_game.keyboard.available_letters = {"b", "c"} - mock_game.state.selected_letters = [] - mock_game.state.current_word = "" - - mock_game._handle_letter_click("a") - - assert mock_game.state.selected_letters == [] - assert mock_game.state.current_word == "" - - def test_submit_word_valid(self, mock_game: "KeyboardCoopGame") -> None: - """Test submitting a valid word adds score.""" - mock_game._generate_random_keyboard = MagicMock() - mock_game.state.current_word = "cat" - mock_game.state.selected_letters = ["c", "a", "t"] - mock_game.state.score = 0 - - mock_game._submit_word() - - assert mock_game.state.score == 2 # 2^(3-2) = 2 - assert mock_game.state.current_word == "" - assert mock_game.state.selected_letters == [] - - def test_submit_word_too_short(self, mock_game: "KeyboardCoopGame") -> None: - """Test submitting too short word gives no score.""" - mock_game.state.current_word = "ca" - mock_game.state.selected_letters = ["c", "a"] - mock_game.state.score = 0 - - mock_game._submit_word() - - assert mock_game.state.score == 0 - assert "too short" in mock_game.state.message - - def test_submit_word_invalid(self, mock_game: "KeyboardCoopGame") -> None: - """Test submitting invalid word gives no score.""" - mock_game.state.current_word = "xyz" - mock_game.state.selected_letters = ["x", "y", "z"] - mock_game.state.score = 0 - - mock_game._submit_word() - - assert mock_game.state.score == 0 - assert "not a valid word" in mock_game.state.message - - def test_reset_game(self, mock_game: "KeyboardCoopGame") -> None: - """Test reset_game creates new state.""" - mock_game._generate_random_keyboard = MagicMock() - mock_game.state.score = 100 - mock_game.state.current_word = "test" - - mock_game._reset_game() - - # After reset, state should be fresh - assert mock_game.state.score == 0 - assert mock_game.state.current_word == "" - assert mock_game._generate_random_keyboard.called - - def test_get_key_at_position_found(self, mock_game: "KeyboardCoopGame") -> None: - """Test getting key at position when key exists.""" - mock_rect = MagicMock() - mock_rect.collidepoint.return_value = True - mock_game.keyboard.positions = {"a": mock_rect} - - result = mock_game._get_key_at_position((100, 100)) - assert result == "a" - - def test_get_key_at_position_not_found(self, mock_game: "KeyboardCoopGame") -> None: - """Test getting key at position when no key.""" - mock_rect = MagicMock() - mock_rect.collidepoint.return_value = False - mock_game.keyboard.positions = {"a": mock_rect} - - result = mock_game._get_key_at_position((100, 100)) - assert result is None - - -class TestLoadDictionary: - """Tests for dictionary loading.""" - - def test_fallback_dictionary_used(self) -> None: - """Test fallback dictionary when file not found.""" - mock_pg = MagicMock() - mock_pg.font.Font.return_value = MagicMock() - mock_pg.display.set_mode.return_value = MagicMock() - - with ( - patch.dict("sys.modules", {"pygame": mock_pg}), - patch("pathlib.Path.open", side_effect=FileNotFoundError), - ): - from python_pkg.keyboard_coop.main import KeyboardCoopGame - - game = object.__new__(KeyboardCoopGame) - dictionary = game._load_dictionary() - - # Should have fallback words - assert "cat" in dictionary - assert "dog" in dictionary - - def test_json_decode_error_fallback(self) -> None: - """Test fallback dictionary when JSON is invalid.""" - import json - - mock_pg = MagicMock() - mock_pg.font.Font.return_value = MagicMock() - mock_pg.display.set_mode.return_value = MagicMock() - - mock_file = MagicMock() - mock_file.__enter__ = MagicMock(return_value=mock_file) - mock_file.__exit__ = MagicMock(return_value=False) - - with ( - patch.dict("sys.modules", {"pygame": mock_pg}), - patch("pathlib.Path.open", return_value=mock_file), - patch("json.load", side_effect=json.JSONDecodeError("err", "doc", 0)), - ): - from python_pkg.keyboard_coop.main import KeyboardCoopGame - - game = object.__new__(KeyboardCoopGame) - dictionary = game._load_dictionary() - - # Should have fallback words from JSONDecodeError handler - assert "cat" in dictionary - assert "dog" in dictionary - - -class TestGenerateRandomKeyboard: - """Tests for keyboard layout generation.""" - - def test_generate_random_keyboard_creates_26_letters(self) -> None: - """Test keyboard generation includes all 26 letters.""" - mock_pg = MagicMock() - mock_pg.Rect = MagicMock(return_value=MagicMock()) - - with patch.dict("sys.modules", {"pygame": mock_pg}): - from python_pkg.keyboard_coop.main import ( - KeyboardCoopGame, - KeyboardState, - ) - - game = object.__new__(KeyboardCoopGame) - game.keyboard = KeyboardState() - - game._generate_random_keyboard() - - # Should have 26 letters total across all rows - all_letters = [] - for row in game.keyboard.layout: - all_letters.extend(row) - assert len(all_letters) == 26 - assert len(set(all_letters)) == 26 # All unique - - def test_layout_structure_is_10_9_7(self) -> None: - """Test keyboard layout has correct row structure.""" - mock_pg = MagicMock() - mock_pg.Rect = MagicMock(return_value=MagicMock()) - - with patch.dict("sys.modules", {"pygame": mock_pg}): - from python_pkg.keyboard_coop.main import ( - KeyboardCoopGame, - KeyboardState, - ) - - game = object.__new__(KeyboardCoopGame) - game.keyboard = KeyboardState() - - game._generate_random_keyboard() - - assert len(game.keyboard.layout) == 3 - assert len(game.keyboard.layout[0]) == 10 - assert len(game.keyboard.layout[1]) == 9 - assert len(game.keyboard.layout[2]) == 7 - - -class TestCalculateAdjacencies: - """Tests for adjacency calculation.""" - - def test_calculate_adjacencies_populates_all_letters(self) -> None: - """Test adjacency calculation includes all letters.""" - mock_pg = MagicMock() - - with patch.dict("sys.modules", {"pygame": mock_pg}): - from python_pkg.keyboard_coop.main import ( - KeyboardCoopGame, - KeyboardState, - ) - - game = object.__new__(KeyboardCoopGame) - game.keyboard = KeyboardState() - game.keyboard.layout = [ - ["a", "b", "c"], - ["d", "e", "f"], - ["g", "h"], - ] - - game._calculate_adjacencies() - - # Each letter should have adjacency list - assert len(game.keyboard.adjacency) == 8 - # Corner letter should have fewer adjacents - assert "b" in game.keyboard.adjacency["a"] - assert "d" in game.keyboard.adjacency["a"] - assert "e" in game.keyboard.adjacency["a"] - - -class TestCalculateKeyPositions: - """Tests for key position calculation.""" - - def test_calculate_key_positions_creates_rects(self) -> None: - """Test key position calculation creates rect for each key.""" - mock_pg = MagicMock() - mock_pg.Rect = MagicMock(return_value=MagicMock()) - - with patch.dict("sys.modules", {"pygame": mock_pg}): - from python_pkg.keyboard_coop.main import ( - KeyboardCoopGame, - KeyboardState, - ) - - game = object.__new__(KeyboardCoopGame) - game.keyboard = KeyboardState() - game.keyboard.layout = [["a", "b"], ["c", "d"]] - - positions = game._calculate_key_positions() - - assert len(positions) == 4 - assert "a" in positions - assert "d" in positions - - -class TestGameInit: - """Tests for game initialization.""" - - def test_init_creates_all_components(self) -> None: - """Test __init__ properly initializes all game components.""" - mock_pg = MagicMock() - mock_pg.font.Font.return_value = MagicMock() - mock_pg.display.set_mode.return_value = MagicMock() - mock_pg.Rect = MagicMock(return_value=MagicMock()) - - mock_file = MagicMock() - mock_file.__enter__ = MagicMock(return_value=mock_file) - mock_file.__exit__ = MagicMock(return_value=False) - - with ( - patch.dict("sys.modules", {"pygame": mock_pg}), - patch("pathlib.Path.open", return_value=mock_file), - patch("json.load", return_value={"cat": 1, "dog": 1}), - ): - from python_pkg.keyboard_coop.main import KeyboardCoopGame - - game = KeyboardCoopGame() - - # Verify pygame display was set up - mock_pg.display.set_mode.assert_called() - mock_pg.display.set_caption.assert_called_with("Keyboard Coop Game") - - # Verify game components are initialized - assert game.screen is not None - assert game.clock is not None - assert game.fonts is not None - assert game.dictionary is not None - assert game.state is not None - assert game.keyboard is not None - - -class TestHandleClick: - """Tests for click handling.""" - - def test_handle_click_on_letter_key(self) -> None: - """Test clicking on a letter key triggers letter click handler.""" - mock_pg = MagicMock() - mock_pg.Rect = MagicMock() - - with patch.dict("sys.modules", {"pygame": mock_pg}): - from python_pkg.keyboard_coop.main import ( - GameState, - KeyboardCoopGame, - KeyboardState, - ) - - game = object.__new__(KeyboardCoopGame) - game.state = GameState() - game.keyboard = KeyboardState() - game.keyboard.available_letters = {"a"} - game.keyboard.adjacency = {"a": []} - - # Mock _get_key_at_position to return "a" - game._get_key_at_position = MagicMock(return_value="a") - game._handle_letter_click = MagicMock() - game._submit_word = MagicMock() - game._reset_game = MagicMock() - - # Create mock rects that don't collide - mock_enter_rect = MagicMock() - mock_enter_rect.collidepoint.return_value = False - mock_reset_rect = MagicMock() - mock_reset_rect.collidepoint.return_value = False - - # Patch pygame.Rect to return our mocks - mock_pg.Rect.side_effect = [mock_enter_rect, mock_reset_rect] - - game._handle_click((100, 100)) - - game._handle_letter_click.assert_called_with("a") - - def test_handle_click_on_enter_button(self) -> None: - """Test clicking ENTER button triggers word submission.""" - mock_pg = MagicMock() - - with patch.dict("sys.modules", {"pygame": mock_pg}): - from python_pkg.keyboard_coop.main import ( - GameState, - KeyboardCoopGame, - KeyboardState, - ) - - game = object.__new__(KeyboardCoopGame) - game.state = GameState() - game.keyboard = KeyboardState() - game.keyboard.positions = {} - - # Mock methods - game._get_key_at_position = MagicMock(return_value=None) - game._submit_word = MagicMock() - game._reset_game = MagicMock() - - # Mock enter button to collide, reset button not to - mock_enter_rect = MagicMock() - mock_enter_rect.collidepoint.return_value = True - mock_reset_rect = MagicMock() - mock_reset_rect.collidepoint.return_value = False - - mock_pg.Rect.side_effect = [mock_enter_rect, mock_reset_rect] - - game._handle_click((750, 200)) - - game._submit_word.assert_called() - game._reset_game.assert_not_called() - - def test_handle_click_on_reset_button(self) -> None: - """Test clicking RESET button triggers game reset.""" - mock_pg = MagicMock() - - with patch.dict("sys.modules", {"pygame": mock_pg}): - from python_pkg.keyboard_coop.main import ( - GameState, - KeyboardCoopGame, - KeyboardState, - ) - - game = object.__new__(KeyboardCoopGame) - game.state = GameState() - game.keyboard = KeyboardState() - game.keyboard.positions = {} - - # Mock methods - game._get_key_at_position = MagicMock(return_value=None) - game._submit_word = MagicMock() - game._reset_game = MagicMock() - - # Mock enter button not to collide, reset button to collide - mock_enter_rect = MagicMock() - mock_enter_rect.collidepoint.return_value = False - mock_reset_rect = MagicMock() - mock_reset_rect.collidepoint.return_value = True - - mock_pg.Rect.side_effect = [mock_enter_rect, mock_reset_rect] - - game._handle_click((900, 200)) - - game._reset_game.assert_called() - game._submit_word.assert_not_called() - - -class TestDrawingMethods: - """Tests for drawing methods.""" - - def test_draw_text_line(self) -> None: - """Test draw_text_line renders and blits text.""" - mock_pg = MagicMock() - mock_font = MagicMock() - mock_rendered = MagicMock() - mock_font.render.return_value = mock_rendered - - with patch.dict("sys.modules", {"pygame": mock_pg}): - from python_pkg.keyboard_coop.main import ( - KeyboardCoopGame, - ) - - game = object.__new__(KeyboardCoopGame) - game.screen = MagicMock() - - game._draw_text_line("Test text", (10, 20), mock_font) - - mock_font.render.assert_called() - game.screen.blit.assert_called_with(mock_rendered, (10, 20)) - - def test_draw_button(self) -> None: - """Test draw_button draws rect and text.""" - mock_pg = MagicMock() - mock_pg.draw = MagicMock() - - with patch.dict("sys.modules", {"pygame": mock_pg}): - from python_pkg.keyboard_coop.main import ( - FontSet, - KeyboardCoopGame, - ) - - game = object.__new__(KeyboardCoopGame) - game.screen = MagicMock() - game.fonts = FontSet( - normal=MagicMock(), large=MagicMock(), small=MagicMock() - ) - - mock_rect = MagicMock() - mock_rect.center = (50, 50) - - game._draw_button(mock_rect, "Test") - - # Should have drawn rect twice (fill and border) - assert mock_pg.draw.rect.call_count == 2 - - -class TestDrawKeyboard: - """Tests for keyboard drawing.""" - - def test_draw_keyboard_draws_all_keys(self) -> None: - """Test draw_keyboard renders all key positions.""" - mock_pg = MagicMock() - mock_pg.draw = MagicMock() - mock_pg.mouse.get_pos.return_value = (0, 0) - - with patch.dict("sys.modules", {"pygame": mock_pg}): - from python_pkg.keyboard_coop.main import ( - FontSet, - GameState, - KeyboardCoopGame, - KeyboardState, - ) - - game = object.__new__(KeyboardCoopGame) - game.screen = MagicMock() - game.state = GameState() - game.keyboard = KeyboardState() - game.fonts = FontSet( - normal=MagicMock(), large=MagicMock(), small=MagicMock() - ) - - # Set up some positions - mock_rect_a = MagicMock() - mock_rect_a.collidepoint.return_value = False - mock_rect_a.center = (100, 100) - mock_rect_b = MagicMock() - mock_rect_b.collidepoint.return_value = False - mock_rect_b.center = (150, 100) - - game.keyboard.positions = {"a": mock_rect_a, "b": mock_rect_b} - game.keyboard.available_letters = {"a", "b"} - - game._draw_keyboard() - - # Should draw rect for each key (fill + border = 2 calls per key) - assert mock_pg.draw.rect.call_count >= 4 - - def test_draw_keyboard_selected_letter_color(self) -> None: - """Test selected letters get selected color.""" - mock_pg = MagicMock() - mock_pg.draw = MagicMock() - mock_pg.mouse.get_pos.return_value = (0, 0) - - with patch.dict("sys.modules", {"pygame": mock_pg}): - from python_pkg.keyboard_coop.main import ( - KEY_SELECTED_COLOR, - FontSet, - GameState, - KeyboardCoopGame, - KeyboardState, - ) - - game = object.__new__(KeyboardCoopGame) - game.screen = MagicMock() - game.state = GameState() - game.state.selected_letters = ["a"] # 'a' is selected - game.keyboard = KeyboardState() - game.fonts = FontSet( - normal=MagicMock(), large=MagicMock(), small=MagicMock() - ) - - mock_rect_a = MagicMock() - mock_rect_a.collidepoint.return_value = False - mock_rect_a.center = (100, 100) - - game.keyboard.positions = {"a": mock_rect_a} - game.keyboard.available_letters = {"a"} - - game._draw_keyboard() - - # Check that KEY_SELECTED_COLOR was used - calls = mock_pg.draw.rect.call_args_list - colors_used = [call[0][1] for call in calls] - assert KEY_SELECTED_COLOR in colors_used - - def test_draw_keyboard_unavailable_key_color(self) -> None: - """Test unavailable keys get default key color.""" - mock_pg = MagicMock() - mock_pg.draw = MagicMock() - - with patch.dict("sys.modules", {"pygame": mock_pg}): - from python_pkg.keyboard_coop.main import ( - KEY_COLOR, - FontSet, - GameState, - KeyboardCoopGame, - KeyboardState, - ) - - game = object.__new__(KeyboardCoopGame) - game.screen = MagicMock() - game.state = GameState() - game.state.selected_letters = [] # Not selected - game.keyboard = KeyboardState() - game.fonts = FontSet( - normal=MagicMock(), large=MagicMock(), small=MagicMock() - ) - - mock_rect_a = MagicMock() - mock_rect_a.center = (100, 100) - - game.keyboard.positions = {"a": mock_rect_a} - # Key is NOT available - should get KEY_COLOR - game.keyboard.available_letters = set() - - game._draw_keyboard() - - # Check that KEY_COLOR was used for unavailable key - calls = mock_pg.draw.rect.call_args_list - colors_used = [call[0][1] for call in calls] - assert KEY_COLOR in colors_used - - -class TestDrawUI: - """Tests for UI drawing.""" - - def test_draw_ui_returns_button_rects(self) -> None: - """Test draw_ui returns enter and reset button rects.""" - mock_pg = MagicMock() - mock_pg.draw = MagicMock() - mock_rect_instance = MagicMock() - mock_pg.Rect.return_value = mock_rect_instance - - with patch.dict("sys.modules", {"pygame": mock_pg}): - from python_pkg.keyboard_coop.main import ( - FontSet, - GameState, - KeyboardCoopGame, - ) - - game = object.__new__(KeyboardCoopGame) - game.screen = MagicMock() - game.state = GameState() - game.fonts = FontSet( - normal=MagicMock(), large=MagicMock(), small=MagicMock() - ) - - enter_rect, reset_rect = game._draw_ui() - - # Should return pygame.Rect instances - assert enter_rect is not None - assert reset_rect is not None - - -class TestForceSubmitWhenNoMoves: - """Tests for forced word submission when no moves available.""" - - def test_submit_called_when_available_letters_empty(self) -> None: - """Test that word is submitted when no valid moves remain. - - This tests the defensive code path at line 351 where _submit_word - is called if available_letters becomes empty after a letter click. - """ - mock_pg = MagicMock() - - with patch.dict("sys.modules", {"pygame": mock_pg}): - from python_pkg.keyboard_coop.main import ( - GameState, - KeyboardCoopGame, - KeyboardState, - ) - - game = object.__new__(KeyboardCoopGame) - game.state = GameState() - game.keyboard = KeyboardState() - game.keyboard.layout = [["a", "b"]] - game.keyboard.adjacency = {} - game.keyboard.available_letters = {"a"} - game.keyboard.positions = {} - game.dictionary = {"a": 1} - - # Simulate scenario where available_letters becomes empty - # This is defensive code that's hard to trigger naturally - game._submit_word = MagicMock() - - def patched_handle(letter: str) -> None: - """Patched handler that clears available letters.""" - if letter in game.keyboard.available_letters: - game.state.selected_letters.append(letter) - game.state.current_word += letter - # Force empty to trigger the check - game.keyboard.available_letters = set() - if not game.keyboard.available_letters: - game._submit_word() - - patched_handle("a") - - # Should have triggered submit_word - game._submit_word.assert_called() - - -class TestGameLoop: - """Tests for the main game loop.""" - - def test_run_quit_event(self) -> None: - """Test game loop exits on QUIT event.""" - mock_pg = MagicMock() - - # Create quit event - quit_event = MagicMock() - quit_event.type = "QUIT" - mock_pg.QUIT = "QUIT" - mock_pg.MOUSEBUTTONDOWN = "MOUSEDOWN" - mock_pg.KEYDOWN = "KEYDOWN" - mock_pg.event.get.return_value = [quit_event] - - with ( - patch.dict("sys.modules", {"pygame": mock_pg}), - patch("sys.exit") as mock_exit, - ): - from python_pkg.keyboard_coop.main import ( - FontSet, - GameState, - KeyboardCoopGame, - KeyboardState, - ) - - game = object.__new__(KeyboardCoopGame) - game.screen = MagicMock() - game.clock = MagicMock() - game.state = GameState() - game.keyboard = KeyboardState() - game.keyboard.positions = {} - game.fonts = FontSet( - normal=MagicMock(), large=MagicMock(), small=MagicMock() - ) - game._draw_keyboard = MagicMock() - game._draw_ui = MagicMock(return_value=(MagicMock(), MagicMock())) - - game.run() - - mock_pg.quit.assert_called() - mock_exit.assert_called() - - def test_run_mouse_click_event(self) -> None: - """Test game loop handles mouse click event.""" - mock_pg = MagicMock() - - # Create mouse click event followed by quit - click_event = MagicMock() - click_event.type = "MOUSEDOWN" - click_event.button = 1 - click_event.pos = (100, 100) - - quit_event = MagicMock() - quit_event.type = "QUIT" - - mock_pg.QUIT = "QUIT" - mock_pg.MOUSEBUTTONDOWN = "MOUSEDOWN" - mock_pg.KEYDOWN = "KEYDOWN" - # Return click event first, then quit event - mock_pg.event.get.side_effect = [[click_event], [quit_event]] - - with ( - patch.dict("sys.modules", {"pygame": mock_pg}), - patch("sys.exit"), - ): - from python_pkg.keyboard_coop.main import ( - FontSet, - GameState, - KeyboardCoopGame, - KeyboardState, - ) - - game = object.__new__(KeyboardCoopGame) - game.screen = MagicMock() - game.clock = MagicMock() - game.state = GameState() - game.keyboard = KeyboardState() - game.keyboard.positions = {} - game.fonts = FontSet( - normal=MagicMock(), large=MagicMock(), small=MagicMock() - ) - game._draw_keyboard = MagicMock() - game._draw_ui = MagicMock(return_value=(MagicMock(), MagicMock())) - game._handle_click = MagicMock() - - game.run() - - game._handle_click.assert_called_with((100, 100)) - - def test_run_enter_key_event(self) -> None: - """Test game loop handles ENTER key event.""" - mock_pg = MagicMock() - - key_event = MagicMock() - key_event.type = "KEYDOWN" - key_event.key = "K_RETURN" - - quit_event = MagicMock() - quit_event.type = "QUIT" - - mock_pg.QUIT = "QUIT" - mock_pg.MOUSEBUTTONDOWN = "MOUSEDOWN" - mock_pg.KEYDOWN = "KEYDOWN" - mock_pg.K_RETURN = "K_RETURN" - mock_pg.K_r = "K_r" - mock_pg.event.get.side_effect = [[key_event], [quit_event]] - - with ( - patch.dict("sys.modules", {"pygame": mock_pg}), - patch("sys.exit"), - ): - from python_pkg.keyboard_coop.main import ( - FontSet, - GameState, - KeyboardCoopGame, - KeyboardState, - ) - - game = object.__new__(KeyboardCoopGame) - game.screen = MagicMock() - game.clock = MagicMock() - game.state = GameState() - game.keyboard = KeyboardState() - game.keyboard.positions = {} - game.fonts = FontSet( - normal=MagicMock(), large=MagicMock(), small=MagicMock() - ) - game._draw_keyboard = MagicMock() - game._draw_ui = MagicMock(return_value=(MagicMock(), MagicMock())) - game._submit_word = MagicMock() - - game.run() - - game._submit_word.assert_called() - - def test_run_r_key_reset(self) -> None: - """Test game loop handles R key for reset.""" - mock_pg = MagicMock() - - key_event = MagicMock() - key_event.type = "KEYDOWN" - key_event.key = "K_r" - - quit_event = MagicMock() - quit_event.type = "QUIT" - - mock_pg.QUIT = "QUIT" - mock_pg.MOUSEBUTTONDOWN = "MOUSEDOWN" - mock_pg.KEYDOWN = "KEYDOWN" - mock_pg.K_RETURN = "K_RETURN" - mock_pg.K_r = "K_r" - mock_pg.event.get.side_effect = [[key_event], [quit_event]] - - with ( - patch.dict("sys.modules", {"pygame": mock_pg}), - patch("sys.exit"), - ): - from python_pkg.keyboard_coop.main import ( - FontSet, - GameState, - KeyboardCoopGame, - KeyboardState, - ) - - game = object.__new__(KeyboardCoopGame) - game.screen = MagicMock() - game.clock = MagicMock() - game.state = GameState() - game.keyboard = KeyboardState() - game.keyboard.positions = {} - game.fonts = FontSet( - normal=MagicMock(), large=MagicMock(), small=MagicMock() - ) - game._draw_keyboard = MagicMock() - game._draw_ui = MagicMock(return_value=(MagicMock(), MagicMock())) - game._reset_game = MagicMock() - - game.run() - - game._reset_game.assert_called() - - def test_run_letter_key_press(self) -> None: - """Test game loop handles letter key presses.""" - mock_pg = MagicMock() - - key_event = MagicMock() - key_event.type = "KEYDOWN" - key_event.key = "some_key" - - quit_event = MagicMock() - quit_event.type = "QUIT" - - mock_pg.QUIT = "QUIT" - mock_pg.MOUSEBUTTONDOWN = "MOUSEDOWN" - mock_pg.KEYDOWN = "KEYDOWN" - mock_pg.K_RETURN = "K_RETURN" - mock_pg.K_r = "K_r" - mock_pg.key.name.return_value = "a" # Single letter key - mock_pg.event.get.side_effect = [[key_event], [quit_event]] - - with ( - patch.dict("sys.modules", {"pygame": mock_pg}), - patch("sys.exit"), - ): - from python_pkg.keyboard_coop.main import ( - FontSet, - GameState, - KeyboardCoopGame, - KeyboardState, - ) - - game = object.__new__(KeyboardCoopGame) - game.screen = MagicMock() - game.clock = MagicMock() - game.state = GameState() - game.keyboard = KeyboardState() - game.keyboard.positions = {} - game.fonts = FontSet( - normal=MagicMock(), large=MagicMock(), small=MagicMock() - ) - game._draw_keyboard = MagicMock() - game._draw_ui = MagicMock(return_value=(MagicMock(), MagicMock())) - game._handle_letter_click = MagicMock() - - game.run() - - game._handle_letter_click.assert_called_with("a") - - def test_run_right_click_ignored(self) -> None: - """Test game loop ignores non-left mouse clicks.""" - mock_pg = MagicMock() - - click_event = MagicMock() - click_event.type = "MOUSEDOWN" - click_event.button = 3 # Right click - click_event.pos = (100, 100) - - quit_event = MagicMock() - quit_event.type = "QUIT" - - mock_pg.QUIT = "QUIT" - mock_pg.MOUSEBUTTONDOWN = "MOUSEDOWN" - mock_pg.KEYDOWN = "KEYDOWN" - mock_pg.event.get.side_effect = [[click_event], [quit_event]] - - with ( - patch.dict("sys.modules", {"pygame": mock_pg}), - patch("sys.exit"), - ): - from python_pkg.keyboard_coop.main import ( - FontSet, - GameState, - KeyboardCoopGame, - KeyboardState, - ) - - game = object.__new__(KeyboardCoopGame) - game.screen = MagicMock() - game.clock = MagicMock() - game.state = GameState() - game.keyboard = KeyboardState() - game.keyboard.positions = {} - game.fonts = FontSet( - normal=MagicMock(), large=MagicMock(), small=MagicMock() - ) - game._draw_keyboard = MagicMock() - game._draw_ui = MagicMock(return_value=(MagicMock(), MagicMock())) - game._handle_click = MagicMock() - - game.run() - - # handle_click should NOT be called for right click - game._handle_click.assert_not_called() - - def test_run_special_key_ignored(self) -> None: - """Test game loop ignores non-letter key presses.""" - mock_pg = MagicMock() - - key_event = MagicMock() - key_event.type = "KEYDOWN" - key_event.key = "some_key" - - quit_event = MagicMock() - quit_event.type = "QUIT" - - mock_pg.QUIT = "QUIT" - mock_pg.MOUSEBUTTONDOWN = "MOUSEDOWN" - mock_pg.KEYDOWN = "KEYDOWN" - mock_pg.K_RETURN = "K_RETURN" - mock_pg.K_r = "K_r" - mock_pg.key.name.return_value = "escape" # Multi-char, not a letter - mock_pg.event.get.side_effect = [[key_event], [quit_event]] - - with ( - patch.dict("sys.modules", {"pygame": mock_pg}), - patch("sys.exit"), - ): - from python_pkg.keyboard_coop.main import ( - FontSet, - GameState, - KeyboardCoopGame, - KeyboardState, - ) - - game = object.__new__(KeyboardCoopGame) - game.screen = MagicMock() - game.clock = MagicMock() - game.state = GameState() - game.keyboard = KeyboardState() - game.keyboard.positions = {} - game.fonts = FontSet( - normal=MagicMock(), large=MagicMock(), small=MagicMock() - ) - game._draw_keyboard = MagicMock() - game._draw_ui = MagicMock(return_value=(MagicMock(), MagicMock())) - game._handle_letter_click = MagicMock() - - game.run() - - # handle_letter_click should NOT be called for special keys - game._handle_letter_click.assert_not_called() - - def test_run_unknown_event_type(self) -> None: - """Test game loop ignores unknown event types.""" - mock_pg = MagicMock() - - unknown_event = MagicMock() - unknown_event.type = "UNKNOWN" - - quit_event = MagicMock() - quit_event.type = "QUIT" - - mock_pg.QUIT = "QUIT" - mock_pg.MOUSEBUTTONDOWN = "MOUSEDOWN" - mock_pg.KEYDOWN = "KEYDOWN" - mock_pg.event.get.side_effect = [[unknown_event], [quit_event]] - - with ( - patch.dict("sys.modules", {"pygame": mock_pg}), - patch("sys.exit"), - ): - from python_pkg.keyboard_coop.main import ( - FontSet, - GameState, - KeyboardCoopGame, - KeyboardState, - ) - - game = object.__new__(KeyboardCoopGame) - game.screen = MagicMock() - game.clock = MagicMock() - game.state = GameState() - game.keyboard = KeyboardState() - game.keyboard.positions = {} - game.fonts = FontSet( - normal=MagicMock(), large=MagicMock(), small=MagicMock() - ) - game._draw_keyboard = MagicMock() - game._draw_ui = MagicMock(return_value=(MagicMock(), MagicMock())) - game._handle_click = MagicMock() - game._submit_word = MagicMock() - game._reset_game = MagicMock() - game._handle_letter_click = MagicMock() - - game.run() - - # None of the handlers should be called for unknown event - game._handle_click.assert_not_called() - game._submit_word.assert_not_called() - game._reset_game.assert_not_called() - game._handle_letter_click.assert_not_called() diff --git a/python_pkg/keyboard_coop/tests/test_ui.py b/python_pkg/keyboard_coop/tests/test_ui.py new file mode 100644 index 0000000..dd346e9 --- /dev/null +++ b/python_pkg/keyboard_coop/tests/test_ui.py @@ -0,0 +1,311 @@ +"""Tests for keyboard_coop UI drawing and click handling.""" + +from __future__ import annotations + +from unittest.mock import MagicMock, patch + + +class TestHandleClick: + """Tests for click handling.""" + + def test_handle_click_on_letter_key(self) -> None: + """Test clicking on a letter key triggers letter click handler.""" + mock_pg = MagicMock() + mock_pg.Rect = MagicMock() + + with patch.dict("sys.modules", {"pygame": mock_pg}): + from python_pkg.keyboard_coop.main import ( + GameState, + KeyboardCoopGame, + KeyboardState, + ) + + game = object.__new__(KeyboardCoopGame) + game.state = GameState() + game.keyboard = KeyboardState() + game.keyboard.available_letters = {"a"} + game.keyboard.adjacency = {"a": []} + + # Mock _get_key_at_position to return "a" + game._get_key_at_position = MagicMock(return_value="a") + game._handle_letter_click = MagicMock() + game._submit_word = MagicMock() + game._reset_game = MagicMock() + + # Create mock rects that don't collide + mock_enter_rect = MagicMock() + mock_enter_rect.collidepoint.return_value = False + mock_reset_rect = MagicMock() + mock_reset_rect.collidepoint.return_value = False + + # Patch pygame.Rect to return our mocks + mock_pg.Rect.side_effect = [mock_enter_rect, mock_reset_rect] + + game._handle_click((100, 100)) + + game._handle_letter_click.assert_called_with("a") + + def test_handle_click_on_enter_button(self) -> None: + """Test clicking ENTER button triggers word submission.""" + mock_pg = MagicMock() + + with patch.dict("sys.modules", {"pygame": mock_pg}): + from python_pkg.keyboard_coop.main import ( + GameState, + KeyboardCoopGame, + KeyboardState, + ) + + game = object.__new__(KeyboardCoopGame) + game.state = GameState() + game.keyboard = KeyboardState() + game.keyboard.positions = {} + + # Mock methods + game._get_key_at_position = MagicMock(return_value=None) + game._submit_word = MagicMock() + game._reset_game = MagicMock() + + # Mock enter button to collide, reset button not to + mock_enter_rect = MagicMock() + mock_enter_rect.collidepoint.return_value = True + mock_reset_rect = MagicMock() + mock_reset_rect.collidepoint.return_value = False + + mock_pg.Rect.side_effect = [mock_enter_rect, mock_reset_rect] + + game._handle_click((750, 200)) + + game._submit_word.assert_called() + game._reset_game.assert_not_called() + + def test_handle_click_on_reset_button(self) -> None: + """Test clicking RESET button triggers game reset.""" + mock_pg = MagicMock() + + with patch.dict("sys.modules", {"pygame": mock_pg}): + from python_pkg.keyboard_coop.main import ( + GameState, + KeyboardCoopGame, + KeyboardState, + ) + + game = object.__new__(KeyboardCoopGame) + game.state = GameState() + game.keyboard = KeyboardState() + game.keyboard.positions = {} + + # Mock methods + game._get_key_at_position = MagicMock(return_value=None) + game._submit_word = MagicMock() + game._reset_game = MagicMock() + + # Mock enter button not to collide, reset button to collide + mock_enter_rect = MagicMock() + mock_enter_rect.collidepoint.return_value = False + mock_reset_rect = MagicMock() + mock_reset_rect.collidepoint.return_value = True + + mock_pg.Rect.side_effect = [mock_enter_rect, mock_reset_rect] + + game._handle_click((900, 200)) + + game._reset_game.assert_called() + game._submit_word.assert_not_called() + + +class TestDrawingMethods: + """Tests for drawing methods.""" + + def test_draw_text_line(self) -> None: + """Test draw_text_line renders and blits text.""" + mock_pg = MagicMock() + mock_font = MagicMock() + mock_rendered = MagicMock() + mock_font.render.return_value = mock_rendered + + with patch.dict("sys.modules", {"pygame": mock_pg}): + from python_pkg.keyboard_coop.main import ( + KeyboardCoopGame, + ) + + game = object.__new__(KeyboardCoopGame) + game.screen = MagicMock() + + game._draw_text_line("Test text", (10, 20), mock_font) + + mock_font.render.assert_called() + game.screen.blit.assert_called_with(mock_rendered, (10, 20)) + + def test_draw_button(self) -> None: + """Test draw_button draws rect and text.""" + mock_pg = MagicMock() + mock_pg.draw = MagicMock() + + with patch.dict("sys.modules", {"pygame": mock_pg}): + from python_pkg.keyboard_coop.main import ( + FontSet, + KeyboardCoopGame, + ) + + game = object.__new__(KeyboardCoopGame) + game.screen = MagicMock() + game.fonts = FontSet( + normal=MagicMock(), large=MagicMock(), small=MagicMock() + ) + + mock_rect = MagicMock() + mock_rect.center = (50, 50) + + game._draw_button(mock_rect, "Test") + + # Should have drawn rect twice (fill and border) + assert mock_pg.draw.rect.call_count == 2 + + +class TestDrawKeyboard: + """Tests for keyboard drawing.""" + + def test_draw_keyboard_draws_all_keys(self) -> None: + """Test draw_keyboard renders all key positions.""" + mock_pg = MagicMock() + mock_pg.draw = MagicMock() + mock_pg.mouse.get_pos.return_value = (0, 0) + + with patch.dict("sys.modules", {"pygame": mock_pg}): + from python_pkg.keyboard_coop.main import ( + FontSet, + GameState, + KeyboardCoopGame, + KeyboardState, + ) + + game = object.__new__(KeyboardCoopGame) + game.screen = MagicMock() + game.state = GameState() + game.keyboard = KeyboardState() + game.fonts = FontSet( + normal=MagicMock(), large=MagicMock(), small=MagicMock() + ) + + # Set up some positions + mock_rect_a = MagicMock() + mock_rect_a.collidepoint.return_value = False + mock_rect_a.center = (100, 100) + mock_rect_b = MagicMock() + mock_rect_b.collidepoint.return_value = False + mock_rect_b.center = (150, 100) + + game.keyboard.positions = {"a": mock_rect_a, "b": mock_rect_b} + game.keyboard.available_letters = {"a", "b"} + + game._draw_keyboard() + + # Should draw rect for each key (fill + border = 2 calls per key) + assert mock_pg.draw.rect.call_count >= 4 + + def test_draw_keyboard_selected_letter_color(self) -> None: + """Test selected letters get selected color.""" + mock_pg = MagicMock() + mock_pg.draw = MagicMock() + mock_pg.mouse.get_pos.return_value = (0, 0) + + with patch.dict("sys.modules", {"pygame": mock_pg}): + from python_pkg.keyboard_coop.main import ( + KEY_SELECTED_COLOR, + FontSet, + GameState, + KeyboardCoopGame, + KeyboardState, + ) + + game = object.__new__(KeyboardCoopGame) + game.screen = MagicMock() + game.state = GameState() + game.state.selected_letters = ["a"] # 'a' is selected + game.keyboard = KeyboardState() + game.fonts = FontSet( + normal=MagicMock(), large=MagicMock(), small=MagicMock() + ) + + mock_rect_a = MagicMock() + mock_rect_a.collidepoint.return_value = False + mock_rect_a.center = (100, 100) + + game.keyboard.positions = {"a": mock_rect_a} + game.keyboard.available_letters = {"a"} + + game._draw_keyboard() + + # Check that KEY_SELECTED_COLOR was used + calls = mock_pg.draw.rect.call_args_list + colors_used = [call[0][1] for call in calls] + assert KEY_SELECTED_COLOR in colors_used + + def test_draw_keyboard_unavailable_key_color(self) -> None: + """Test unavailable keys get default key color.""" + mock_pg = MagicMock() + mock_pg.draw = MagicMock() + + with patch.dict("sys.modules", {"pygame": mock_pg}): + from python_pkg.keyboard_coop.main import ( + KEY_COLOR, + FontSet, + GameState, + KeyboardCoopGame, + KeyboardState, + ) + + game = object.__new__(KeyboardCoopGame) + game.screen = MagicMock() + game.state = GameState() + game.state.selected_letters = [] # Not selected + game.keyboard = KeyboardState() + game.fonts = FontSet( + normal=MagicMock(), large=MagicMock(), small=MagicMock() + ) + + mock_rect_a = MagicMock() + mock_rect_a.center = (100, 100) + + game.keyboard.positions = {"a": mock_rect_a} + # Key is NOT available - should get KEY_COLOR + game.keyboard.available_letters = set() + + game._draw_keyboard() + + # Check that KEY_COLOR was used for unavailable key + calls = mock_pg.draw.rect.call_args_list + colors_used = [call[0][1] for call in calls] + assert KEY_COLOR in colors_used + + +class TestDrawUI: + """Tests for UI drawing.""" + + def test_draw_ui_returns_button_rects(self) -> None: + """Test draw_ui returns enter and reset button rects.""" + mock_pg = MagicMock() + mock_pg.draw = MagicMock() + mock_rect_instance = MagicMock() + mock_pg.Rect.return_value = mock_rect_instance + + with patch.dict("sys.modules", {"pygame": mock_pg}): + from python_pkg.keyboard_coop.main import ( + FontSet, + GameState, + KeyboardCoopGame, + ) + + game = object.__new__(KeyboardCoopGame) + game.screen = MagicMock() + game.state = GameState() + game.fonts = FontSet( + normal=MagicMock(), large=MagicMock(), small=MagicMock() + ) + + enter_rect, reset_rect = game._draw_ui() + + # Should return pygame.Rect instances + assert enter_rect is not None + assert reset_rect is not None diff --git a/python_pkg/lichess_bot/tests/test_main.py b/python_pkg/lichess_bot/tests/test_main.py deleted file mode 100644 index d92991c..0000000 --- a/python_pkg/lichess_bot/tests/test_main.py +++ /dev/null @@ -1,1244 +0,0 @@ -"""Tests for lichess_bot main module.""" - -from __future__ import annotations - -import os -import threading -from typing import TYPE_CHECKING, Any -from unittest.mock import MagicMock, PropertyMock, patch - -import chess -import pytest -import requests - -from python_pkg.lichess_bot.main import ( - BotContext, - GameMeta, - GameState, - _apply_move_to_board, - _attempt_move, - _calculate_time_budget, - _collect_analysis_lines, - _extract_game_full_data, - _extract_game_state_data, - _extract_player_info, - _finalize_game, - _handle_challenge, - _handle_game, - _handle_move_if_needed, - _init_game_log, - _insert_analysis_into_log, - _is_my_turn, - _log_analysis_progress, - _log_move_to_file, - _process_analysis_output, - _process_bot_event, - _process_game_event, - _process_game_events_loop, - _rebuild_board_from_moves, - _run_analysis_subprocess, - _run_event_loop, - _run_event_loop_iteration, - _safe_event_loop_iteration, - _stream_bot_events, - _update_clocks_from_state, - _write_pgn_to_log, - main, - run_bot, -) - -if TYPE_CHECKING: - from pathlib import Path - -# Type alias to make mypy happy with test event dicts -Event = dict[str, Any] -GameThreads = dict[str, threading.Thread] - - -class TestApplyMoveToBoard: - """Tests for _apply_move_to_board.""" - - def test_apply_valid_move(self) -> None: - """Test applying a valid move.""" - board = chess.Board() - _apply_move_to_board(board, "e2e4", "game1") - assert board.fen() != chess.STARTING_FEN - - def test_apply_invalid_move(self) -> None: - """Test applying an invalid move logs debug.""" - board = chess.Board() - with patch("python_pkg.lichess_bot.main._logger") as mock_logger: - _apply_move_to_board(board, "invalid", "game1") - mock_logger.debug.assert_called_once() - - -class TestInitGameLog: - """Tests for _init_game_log.""" - - def test_init_game_log_success(self, tmp_path: Path) -> None: - """Test successful log initialization.""" - with patch("python_pkg.lichess_bot.main.Path.cwd", return_value=tmp_path): - result = _init_game_log("game123", 42) - assert result is not None - assert result.exists() - content = result.read_text() - assert "game game123 started" in content - assert "bot_version v42" in content - - def test_init_game_log_oserror(self) -> None: - """Test log initialization with OSError.""" - with patch("python_pkg.lichess_bot.main.Path.cwd") as mock_cwd: - mock_path = MagicMock() - mock_path.__truediv__ = MagicMock(return_value=mock_path) - mock_path.open.side_effect = OSError("Permission denied") - mock_cwd.return_value = mock_path - result = _init_game_log("game123", 42) - assert result is None - - -class TestUpdateClocksFromState: - """Tests for _update_clocks_from_state.""" - - def test_update_clocks_white(self) -> None: - """Test clock update when playing as white.""" - state = GameState(color="white") - state_data: Event = {"wtime": 60000, "btime": 55000, "winc": 1000} - _update_clocks_from_state(state_data, state) - assert state.my_ms == 60000 - assert state.opp_ms == 55000 - assert state.inc_ms == 1000 - - def test_update_clocks_black(self) -> None: - """Test clock update when playing as black.""" - state = GameState(color="black") - state_data: Event = {"wtime": 60000, "btime": 55000, "binc": 2000} - _update_clocks_from_state(state_data, state) - assert state.my_ms == 55000 - assert state.opp_ms == 60000 - assert state.inc_ms == 2000 - - def test_update_clocks_float_values(self) -> None: - """Test clock update with float values.""" - state = GameState(color="white") - state_data: Event = {"wtime": 60000.5, "btime": 55000.5} - _update_clocks_from_state(state_data, state) - assert state.my_ms == 60000 - assert state.opp_ms == 55000 - - def test_update_clocks_none_values(self) -> None: - """Test clock update with None values.""" - state = GameState(color="white") - state_data: Event = {"wtime": None, "btime": None} - _update_clocks_from_state(state_data, state) - assert state.my_ms is None - assert state.opp_ms is None - - -class TestExtractPlayerInfo: - """Tests for _extract_player_info.""" - - def test_extract_player_info_white(self) -> None: - """Test extracting player info when bot is white.""" - api = MagicMock() - api.get_my_user_id.return_value = "mybot" - state = GameState() - meta = GameMeta(game_id="game1", bot_version=1) - event: Event = { - "white": {"id": "mybot", "name": "MyBot"}, - "black": {"id": "opp", "name": "Opponent"}, - } - _extract_player_info(event, state, meta, api) - assert state.color == "white" - assert meta.white_name == "MyBot" - assert meta.black_name == "Opponent" - - def test_extract_player_info_black(self) -> None: - """Test extracting player info when bot is black.""" - api = MagicMock() - api.get_my_user_id.return_value = "mybot" - state = GameState() - meta = GameMeta(game_id="game1", bot_version=1) - event: Event = { - "white": {"id": "opp", "name": "Opponent"}, - "black": {"id": "mybot", "name": "MyBot"}, - } - _extract_player_info(event, state, meta, api) - assert state.color == "black" - - def test_extract_player_info_invalid_data(self) -> None: - """Test extracting player info with invalid data.""" - api = MagicMock() - state = GameState() - meta = GameMeta(game_id="game1", bot_version=1) - event: Event = {"white": "invalid", "black": "invalid"} - _extract_player_info(event, state, meta, api) - assert state.color is None - - def test_extract_player_info_missing_name(self) -> None: - """Test extracting player info with missing name uses id.""" - api = MagicMock() - api.get_my_user_id.return_value = "mybot" - state = GameState() - meta = GameMeta(game_id="game1", bot_version=1) - event: Event = { - "white": {"id": "mybot"}, - "black": {"id": "opponent"}, - } - _extract_player_info(event, state, meta, api) - assert meta.white_name == "mybot" - assert meta.black_name == "opponent" - - -class TestExtractGameFullData: - """Tests for _extract_game_full_data.""" - - def test_extract_game_full_data(self) -> None: - """Test extracting gameFull data.""" - api = MagicMock() - api.get_my_user_id.return_value = "mybot" - state = GameState() - meta = GameMeta(game_id="game1", bot_version=1) - event: Event = { - "state": {"moves": "e2e4 e7e5", "status": "started", "wtime": 60000}, - "white": {"id": "mybot"}, - "black": {"id": "opp"}, - "createdAt": 1609459200000, # 2021-01-01 - } - moves, status = _extract_game_full_data(event, state, meta, api) - assert moves == "e2e4 e7e5" - assert status == "started" - assert meta.site_url == "https://lichess.org/game1" - assert meta.date_iso == "2021.01.01" - - def test_extract_game_full_data_invalid_state(self) -> None: - """Test extracting gameFull data with invalid state.""" - api = MagicMock() - state = GameState() - meta = GameMeta(game_id="game1", bot_version=1) - event: Event = {"state": "invalid"} - moves, status = _extract_game_full_data(event, state, meta, api) - assert moves == "" - assert status is None - - -class TestExtractGameStateData: - """Tests for _extract_game_state_data.""" - - def test_extract_game_state_as_white(self) -> None: - """Test extracting gameState data as white.""" - state = GameState(color="white", my_ms=60000) - event: Event = { - "moves": "e2e4", - "status": "started", - "wtime": 59000, - "btime": 60000, - } - moves, status = _extract_game_state_data(event, state) - assert moves == "e2e4" - assert status == "started" - assert state.my_ms == 59000 - assert state.opp_ms == 60000 - - def test_extract_game_state_as_black(self) -> None: - """Test extracting gameState data as black.""" - state = GameState(color="black") - event: Event = { - "moves": "e2e4 e7e5", - "wtime": 60000, - "btime": 59000, - "binc": 1000, - } - moves, __status = _extract_game_state_data(event, state) - assert moves == "e2e4 e7e5" - assert state.my_ms == 59000 - assert state.opp_ms == 60000 - assert state.inc_ms == 1000 - - -class TestCalculateTimeBudget: - """Tests for _calculate_time_budget.""" - - def test_calculate_time_budget_normal(self) -> None: - """Test time budget calculation.""" - state = GameState(my_ms=60000, inc_ms=1000) - board = chess.Board() - budget = _calculate_time_budget(state, board, 10.0) - assert 0.05 <= budget <= 10.0 - - def test_calculate_time_budget_low_time(self) -> None: - """Test time budget with low time.""" - state = GameState(my_ms=1000, inc_ms=0) - board = chess.Board() - budget = _calculate_time_budget(state, board, 10.0) - assert budget >= 0.05 - - -class TestLogMoveToFile: - """Tests for _log_move_to_file.""" - - def test_log_move_to_file(self, tmp_path: Path) -> None: - """Test logging a move to file.""" - log_path = tmp_path / "game.log" - log_path.write_text("header\n") - move = chess.Move.from_uci("e2e4") - _log_move_to_file(log_path, 1, move, "best move") - content = log_path.read_text() - assert "ply 1: e2e4" in content - assert "best move" in content - - def test_log_move_to_file_none_path(self) -> None: - """Test logging with None path does nothing.""" - move = chess.Move.from_uci("e2e4") - _log_move_to_file(None, 1, move, "reason") # Should not raise - - -class TestAttemptMove: - """Tests for _attempt_move.""" - - def test_attempt_move_success(self) -> None: - """Test successful move attempt.""" - api = MagicMock() - engine = MagicMock() - engine.max_time_sec = 5.0 - engine.choose_move_with_explanation.return_value = ( - chess.Move.from_uci("e2e4"), - "opening", - ) - ctx = BotContext(api=api, engine=engine, bot_version=1) - state = GameState(my_ms=60000) - meta = GameMeta(game_id="game1", bot_version=1) - board = chess.Board() - - result = _attempt_move(ctx, state, meta, board) - assert result is True - api.make_move.assert_called_once() - - def test_attempt_move_no_moves(self) -> None: - """Test move attempt with no legal moves.""" - api = MagicMock() - engine = MagicMock() - engine.max_time_sec = 5.0 - engine.choose_move_with_explanation.return_value = (None, "no moves") - ctx = BotContext(api=api, engine=engine, bot_version=1) - state = GameState() - meta = GameMeta(game_id="game1", bot_version=1) - board = chess.Board() - - result = _attempt_move(ctx, state, meta, board) - assert result is False - - def test_attempt_move_illegal(self) -> None: - """Test move attempt with illegal move.""" - api = MagicMock() - engine = MagicMock() - engine.max_time_sec = 5.0 - # Return a move that's not legal (e.g., random square move) - engine.choose_move_with_explanation.return_value = ( - chess.Move.from_uci("a1a8"), - "bad", - ) - ctx = BotContext(api=api, engine=engine, bot_version=1) - state = GameState(my_ms=60000) - meta = GameMeta(game_id="game1", bot_version=1) - board = chess.Board() - - result = _attempt_move(ctx, state, meta, board) - assert result is True - api.make_move.assert_not_called() - - def test_attempt_move_request_error(self) -> None: - """Test move attempt with request error.""" - api = MagicMock() - api.make_move.side_effect = requests.RequestException("Network error") - engine = MagicMock() - engine.max_time_sec = 5.0 - engine.choose_move_with_explanation.return_value = ( - chess.Move.from_uci("e2e4"), - "opening", - ) - ctx = BotContext(api=api, engine=engine, bot_version=1) - state = GameState(my_ms=60000) - meta = GameMeta(game_id="game1", bot_version=1) - board = chess.Board() - - result = _attempt_move(ctx, state, meta, board) - assert result is True # Still returns True - - -class TestIsMyTurn: - """Tests for _is_my_turn.""" - - def test_is_my_turn_white_to_move(self) -> None: - """Test checking turn when white to move.""" - board = chess.Board() # White to move - assert _is_my_turn(board, "white") is True - assert _is_my_turn(board, "black") is False - - def test_is_my_turn_black_to_move(self) -> None: - """Test checking turn when black to move.""" - board = chess.Board() - board.push_uci("e2e4") # Black to move - assert _is_my_turn(board, "white") is False - assert _is_my_turn(board, "black") is True - - -class TestRebuildBoardFromMoves: - """Tests for _rebuild_board_from_moves.""" - - def test_rebuild_board_from_moves(self) -> None: - """Test rebuilding board from moves list.""" - moves_list = ["e2e4", "e7e5", "g1f3"] - board = _rebuild_board_from_moves(moves_list, "game1") - assert len(board.move_stack) == 3 - - -class TestHandleMoveIfNeeded: - """Tests for _handle_move_if_needed.""" - - def test_handle_move_game_state_my_turn(self) -> None: - """Test handling move on gameState when my turn.""" - api = MagicMock() - engine = MagicMock() - engine.max_time_sec = 5.0 - engine.choose_move_with_explanation.return_value = ( - chess.Move.from_uci("e2e4"), - "opening", - ) - ctx = BotContext(api=api, engine=engine, bot_version=1) - state = GameState(color="white", my_ms=60000, board=chess.Board()) - meta = GameMeta(game_id="game1", bot_version=1) - - result = _handle_move_if_needed(ctx, state, meta, "gameState", 0) - assert result is True - - def test_handle_move_game_full_with_moves(self) -> None: - """Test handling move on gameFull with existing moves (opponent's turn).""" - api = MagicMock() - engine = MagicMock() - ctx = BotContext(api=api, engine=engine, bot_version=1) - state = GameState(color="white", my_ms=60000, board=chess.Board()) - meta = GameMeta(game_id="game1", bot_version=1) - - # gameFull with moves - don't move - result = _handle_move_if_needed(ctx, state, meta, "gameFull", 1) - assert result is True - engine.choose_move_with_explanation.assert_not_called() - - -class TestProcessGameEvent: - """Tests for _process_game_event.""" - - def test_process_game_event_unhandled_type(self) -> None: - """Test processing unhandled event type.""" - ctx = MagicMock() - state = GameState() - meta = GameMeta(game_id="game1", bot_version=1) - event: Event = {"type": "chatLine", "text": "hello"} - result = _process_game_event(event, ctx, state, meta) - assert result is True - - def test_process_game_event_game_full(self) -> None: - """Test processing gameFull event.""" - api = MagicMock() - api.get_my_user_id.return_value = "mybot" - engine = MagicMock() - engine.max_time_sec = 5.0 - engine.choose_move_with_explanation.return_value = ( - chess.Move.from_uci("e2e4"), - "opening", - ) - ctx = BotContext(api=api, engine=engine, bot_version=1) - state = GameState() - meta = GameMeta(game_id="game1", bot_version=1) - event: Event = { - "type": "gameFull", - "state": {"moves": "", "status": "started"}, - "white": {"id": "mybot"}, - "black": {"id": "opp"}, - } - result = _process_game_event(event, ctx, state, meta) - assert result is True - - def test_process_game_event_game_end(self) -> None: - """Test processing game end event.""" - api = MagicMock() - api.get_my_user_id.return_value = "mybot" - engine = MagicMock() - engine.max_time_sec = 5.0 - engine.choose_move_with_explanation.return_value = (None, "no moves") - ctx = BotContext(api=api, engine=engine, bot_version=1) - state = GameState(color="white", last_handled_len=-1) - meta = GameMeta(game_id="game1", bot_version=1) - event: Event = { - "type": "gameState", - "moves": "e2e4 e7e5", - "status": "mate", - } - result = _process_game_event(event, ctx, state, meta) - assert result is False - - def test_process_game_event_game_end_after_move(self) -> None: - """Test game ends with status after handling move. - - This covers the case where _handle_move_if_needed returns True - but status indicates game end. - """ - api = MagicMock() - api.get_my_user_id.return_value = "mybot" - engine = MagicMock() - engine.max_time_sec = 5.0 - engine.choose_move_with_explanation.return_value = ( - chess.Move.from_uci("d2d4"), - "response", - ) - ctx = BotContext(api=api, engine=engine, bot_version=1) - # Black's turn - it's opponent's move, so we don't need to move - state = GameState(color="black", last_handled_len=-1) - meta = GameMeta(game_id="game1", bot_version=1) - event: Event = { - "type": "gameState", - "moves": "e2e4", # One move - now it's black's turn - "status": "resign", # Game ended with resign - } - result = _process_game_event(event, ctx, state, meta) - assert result is False # Game should end - - def test_process_game_event_unchanged_position(self) -> None: - """Test processing event with unchanged position.""" - api = MagicMock() - ctx = BotContext(api=api, engine=MagicMock(), bot_version=1) - state = GameState(last_handled_len=2, color="white") - meta = GameMeta(game_id="game1", bot_version=1) - event: Event = {"type": "gameState", "moves": "e2e4 e7e5"} - result = _process_game_event(event, ctx, state, meta) - assert result is True - - def test_process_game_event_color_unknown(self) -> None: - """Test processing event with unknown color.""" - api = MagicMock() - api.get_my_user_id.return_value = "mybot" - ctx = BotContext(api=api, engine=MagicMock(), bot_version=1) - state = GameState(last_handled_len=-1) - meta = GameMeta(game_id="game1", bot_version=1) - event: Event = {"type": "gameState", "moves": "e2e4"} - result = _process_game_event(event, ctx, state, meta) - assert result is True - assert state.last_handled_len == 1 - - def test_process_game_event_color_unknown_on_gamefull(self) -> None: - """Test processing gameFull event with still unknown color. - - This covers the branch where event_type is gameFull but color - is not determined (e.g., spectator watching game). - """ - api = MagicMock() - # Return a user id that doesn't match either player - api.get_my_user_id.return_value = "spectator" - ctx = BotContext(api=api, engine=MagicMock(), bot_version=1) - state = GameState(last_handled_len=-1) - meta = GameMeta(game_id="game1", bot_version=1) - event: Event = { - "type": "gameFull", - "state": {"moves": "e2e4", "status": "started"}, - "white": {"id": "player1"}, - "black": {"id": "player2"}, - } - result = _process_game_event(event, ctx, state, meta) - assert result is True - # last_handled_len should NOT be updated for gameFull with unknown color - assert state.last_handled_len == -1 - - -class TestWritePgnToLog: - """Tests for _write_pgn_to_log.""" - - def test_write_pgn_to_log(self, tmp_path: Path) -> None: - """Test writing PGN to log file.""" - log_path = tmp_path / "game.log" - log_path.write_text("header\n") - board = chess.Board() - board.push_uci("e2e4") - meta = GameMeta( - game_id="game1", - bot_version=1, - site_url="https://lichess.org/game1", - date_iso="2021.01.01", - white_name="White", - black_name="Black", - ) - _write_pgn_to_log(log_path, board, meta) - content = log_path.read_text() - assert "PGN:" in content - assert "e4" in content - - -class TestRunAnalysisSubprocess: - """Tests for _run_analysis_subprocess.""" - - def test_run_analysis_subprocess_script_not_found(self, tmp_path: Path) -> None: - """Test analysis when script not found.""" - log_path = tmp_path / "game.log" - with patch("python_pkg.lichess_bot.main.Path") as mock_path: - mock_script = MagicMock() - mock_script.is_file.return_value = False - resolve = mock_path.return_value.resolve.return_value - resolve.parent.parent.__truediv__.return_value.__truediv__.return_value = ( - mock_script - ) - result = _run_analysis_subprocess("game1", log_path, 10) - assert result is None - - def test_run_analysis_subprocess_success(self, tmp_path: Path) -> None: - """Test successful analysis subprocess.""" - log_path = tmp_path / "game.log" - log_path.write_text("test") - - mock_proc = MagicMock() - mock_proc.stdout = iter([" 1 e4\n", " 2 e5\n"]) - mock_proc.stderr.read.return_value = "" - mock_proc.wait.return_value = 0 - mock_proc.__enter__ = MagicMock(return_value=mock_proc) - mock_proc.__exit__ = MagicMock(return_value=False) - - with ( - patch("python_pkg.lichess_bot.main.Path") as mock_path, - patch("subprocess.Popen", return_value=mock_proc), - ): - mock_script = MagicMock() - mock_script.is_file.return_value = True - resolve = mock_path.return_value.resolve.return_value - resolve.parent.parent.__truediv__.return_value.__truediv__.return_value = ( - mock_script - ) - result = _run_analysis_subprocess("game1", log_path, 2) - - assert result is not None - - -class TestProcessAnalysisOutput: - """Tests for _process_analysis_output.""" - - def test_process_analysis_output_success(self) -> None: - """Test processing analysis output successfully.""" - mock_proc = MagicMock() - mock_proc.stdout = iter([" 1 e4\n", " 2 e5\n"]) - mock_proc.stderr.read.return_value = "" - mock_proc.wait.return_value = 0 - - result = _process_analysis_output(mock_proc, "game1", 2) - assert result is not None - assert "e4" in result - - def test_process_analysis_output_error_exit(self) -> None: - """Test processing analysis output with error exit.""" - mock_proc = MagicMock() - mock_proc.stdout = iter(["output\n"]) - mock_proc.stderr.read.return_value = "error message" - mock_proc.wait.return_value = 1 - - result = _process_analysis_output(mock_proc, "game1", 1) - assert result is not None - assert "stderr" in result - - def test_process_analysis_output_error_exit_no_stderr(self) -> None: - """Test processing analysis output with error exit but no stderr.""" - mock_proc = MagicMock() - mock_proc.stdout = iter(["output\n"]) - mock_proc.stderr.read.return_value = "" - mock_proc.wait.return_value = 1 - - result = _process_analysis_output(mock_proc, "game1", 1) - assert result is not None - assert "stderr" not in result - - def test_process_analysis_output_none_pipes(self) -> None: - """Test processing analysis output with None pipes.""" - mock_proc = MagicMock() - mock_proc.stdout = None - mock_proc.stderr = None - - with pytest.raises(RuntimeError, match="pipes unexpectedly None"): - _process_analysis_output(mock_proc, "game1", 1) - - -class TestCollectAnalysisLines: - """Tests for _collect_analysis_lines helper.""" - - def test_collect_analysis_lines_empty_iterator(self) -> None: - """Test collecting lines from empty iterator.""" - empty_iter: list[str] = [] - analyzed, lines = _collect_analysis_lines(iter(empty_iter), "game1", 10) - assert analyzed == 0 - assert lines == [] - - def test_collect_analysis_lines_with_content(self) -> None: - """Test collecting lines from iterator with content.""" - content = [" 1 e4\n", " 2 e5\n", "not a ply line\n"] - analyzed, lines = _collect_analysis_lines(iter(content), "game1", 3) - assert analyzed == 2 - assert lines == content - - def test_collect_analysis_lines_full_iteration(self) -> None: - """Test that all lines are collected.""" - content = ["line1\n", " 3 Nf3\n", "line3\n"] - analyzed, lines = _collect_analysis_lines(iter(content), "game1", 1) - assert analyzed == 1 - assert len(lines) == 3 - - -class TestLogAnalysisProgress: - """Tests for _log_analysis_progress.""" - - def test_log_analysis_progress_with_total(self) -> None: - """Test logging progress with known total.""" - with patch("python_pkg.lichess_bot.main._logger") as mock_logger: - _log_analysis_progress("game1", 5, 10) - mock_logger.info.assert_called_once() - call_args = mock_logger.info.call_args[0] - assert "50%" in call_args[0] % call_args[1:] - - def test_log_analysis_progress_zero_total(self) -> None: - """Test logging progress with zero total.""" - with patch("python_pkg.lichess_bot.main._logger") as mock_logger: - _log_analysis_progress("game1", 5, 0) - mock_logger.info.assert_called_once() - call_args = mock_logger.info.call_args[0] - assert "unknown" in call_args[0] - - -class TestInsertAnalysisIntoLog: - """Tests for _insert_analysis_into_log.""" - - def test_insert_analysis_before_pgn(self, tmp_path: Path) -> None: - """Test inserting analysis before PGN section.""" - log_path = tmp_path / "game.log" - log_path.write_text("header\n\nPGN:\n1. e4\n") - meta = GameMeta( - game_id="game1", - bot_version=1, - date_iso="2021.01.01", - white_name="White", - black_name="Black", - ) - _insert_analysis_into_log(log_path, "Analysis here", meta) - content = log_path.read_text() - assert "ANALYSIS:" in content - assert content.index("ANALYSIS:") < content.index("PGN:") - - def test_insert_analysis_at_start(self, tmp_path: Path) -> None: - """Test inserting analysis when PGN at start.""" - log_path = tmp_path / "game.log" - log_path.write_text("PGN:\n1. e4\n") - meta = GameMeta(game_id="game1", bot_version=1) - _insert_analysis_into_log(log_path, "Analysis here", meta) - content = log_path.read_text() - assert "ANALYSIS:" in content - - def test_insert_analysis_no_pgn(self, tmp_path: Path) -> None: - """Test inserting analysis when no PGN section.""" - log_path = tmp_path / "game.log" - log_path.write_text("header\n") - meta = GameMeta(game_id="game1", bot_version=1) - _insert_analysis_into_log(log_path, "Analysis here", meta) - content = log_path.read_text() - assert "ANALYSIS:" in content - - def test_insert_analysis_oserror(self, tmp_path: Path) -> None: - """Test inserting analysis with OSError.""" - log_path = tmp_path / "nonexistent" / "game.log" - meta = GameMeta(game_id="game1", bot_version=1) - # Should not raise, just log debug - _insert_analysis_into_log(log_path, "Analysis", meta) - - -class TestFinalizeGame: - """Tests for _finalize_game.""" - - def test_finalize_game_no_log_path(self) -> None: - """Test finalize game with no log path.""" - state = GameState(log_path=None) - meta = GameMeta(game_id="game1", bot_version=1) - _finalize_game(state, meta) # Should not raise - - def test_finalize_game_write_error(self, tmp_path: Path) -> None: - """Test finalize game with write error.""" - log_path = tmp_path / "game.log" - log_path.write_text("header") - state = GameState(log_path=log_path) - meta = GameMeta(game_id="game1", bot_version=1) - - with patch( - "python_pkg.lichess_bot.main._write_pgn_to_log", - side_effect=OSError("error"), - ): - _finalize_game(state, meta) # Should not raise - - def test_finalize_game_type_error_on_move_stack(self, tmp_path: Path) -> None: - """Test finalize game with TypeError on move_stack.""" - log_path = tmp_path / "game.log" - log_path.write_text("header\n") - state = GameState(log_path=log_path) - meta = GameMeta(game_id="game1", bot_version=1) - - mock_board = MagicMock() - # Use PropertyMock to raise TypeError when move_stack is accessed - type(mock_board).move_stack = PropertyMock(side_effect=TypeError()) - state.board = mock_board - - with patch("python_pkg.lichess_bot.main._write_pgn_to_log"): - _finalize_game(state, meta) # Should not raise - - def test_finalize_game_analysis_error(self, tmp_path: Path) -> None: - """Test finalize game with analysis error.""" - log_path = tmp_path / "game.log" - log_path.write_text("header\n") - state = GameState(log_path=log_path) - meta = GameMeta(game_id="game1", bot_version=1) - - with ( - patch("python_pkg.lichess_bot.main._write_pgn_to_log"), - patch( - "python_pkg.lichess_bot.main._run_analysis_subprocess", - side_effect=OSError("error"), - ), - ): - _finalize_game(state, meta) # Should not raise - - -class TestHandleGame: - """Tests for _handle_game.""" - - def test_handle_game_success(self, tmp_path: Path) -> None: - """Test handling a game successfully.""" - api = MagicMock() - api.get_my_user_id.return_value = "mybot" - api.stream_game_events.return_value = iter( - [ - { - "type": "gameFull", - "state": {"moves": "", "status": "started"}, - "white": {"id": "mybot"}, - "black": {"id": "opp"}, - }, - {"type": "gameState", "moves": "e2e4", "status": "mate"}, - ] - ) - engine = MagicMock() - engine.max_time_sec = 5.0 - engine.choose_move_with_explanation.return_value = (None, "no moves") - ctx = BotContext(api=api, engine=engine, bot_version=1) - - with ( - patch("python_pkg.lichess_bot.main.Path.cwd", return_value=tmp_path), - patch( - "python_pkg.lichess_bot.main._run_analysis_subprocess", - return_value=None, - ), - ): - _handle_game("game1", ctx, None) - - def test_handle_game_request_error(self, tmp_path: Path) -> None: - """Test handling a game with request error.""" - api = MagicMock() - api.stream_game_events.side_effect = requests.RequestException("error") - ctx = BotContext(api=api, engine=MagicMock(), bot_version=1) - - with ( - patch("python_pkg.lichess_bot.main.Path.cwd", return_value=tmp_path), - patch( - "python_pkg.lichess_bot.main._run_analysis_subprocess", - return_value=None, - ), - ): - _handle_game("game1", ctx, None) # Should not raise - - def test_handle_game_skips_chat_events(self, tmp_path: Path) -> None: - """Test handling a game skips chat events.""" - api = MagicMock() - api.stream_game_events.return_value = iter( - [ - {"type": "chatLine", "text": "hello"}, - {"type": "opponentGone", "gone": True}, - ] - ) - ctx = BotContext(api=api, engine=MagicMock(), bot_version=1) - - with ( - patch("python_pkg.lichess_bot.main.Path.cwd", return_value=tmp_path), - patch( - "python_pkg.lichess_bot.main._run_analysis_subprocess", - return_value=None, - ), - ): - _handle_game("game1", ctx, None) - - -class TestProcessGameEventsLoop: - """Tests for _process_game_events_loop.""" - - def test_empty_events_iterator(self) -> None: - """Test processing empty events iterator.""" - api = MagicMock() - ctx = BotContext(api=api, engine=MagicMock(), bot_version=1) - state = GameState(color="white") - meta = GameMeta(game_id="game1", bot_version=1) - - empty_iter: list[Event] = [] - # Should complete without error when iterator is empty - _process_game_events_loop(iter(empty_iter), ctx, state, meta) - - def test_processes_all_events(self) -> None: - """Test that all events are processed until break condition.""" - api = MagicMock() - engine = MagicMock() - engine.max_time_sec = 5.0 - engine.choose_move_with_explanation.return_value = (None, "no moves") - ctx = BotContext(api=api, engine=engine, bot_version=1) - state = GameState(color="white") - meta = GameMeta(game_id="game1", bot_version=1) - - events: list[Event] = [ - {"type": "chatLine", "text": "hello"}, # skipped - {"type": "gameState", "moves": "e2e4", "status": "resign"}, # game end - ] - _process_game_events_loop(iter(events), ctx, state, meta) - - def test_processes_multiple_game_events(self) -> None: - """Test processing multiple game events that continue the game.""" - api = MagicMock() - engine = MagicMock() - engine.max_time_sec = 5.0 - engine.choose_move_with_explanation.return_value = ( - chess.Move.from_uci("e2e4"), - "e4", - ) - api.make_move.return_value = None - ctx = BotContext(api=api, engine=engine, bot_version=1) - state = GameState(color="white") - state.board = chess.Board() - meta = GameMeta(game_id="game1", bot_version=1) - - events: list[Event] = [ - # First event - game state, game continues - {"type": "gameState", "moves": "", "status": "started"}, - # Second event - opponent moves, game continues - {"type": "gameState", "moves": "e2e4 e7e5", "status": "started"}, - # Third event - game ends - {"type": "gameState", "moves": "e2e4 e7e5", "status": "mate"}, - ] - _process_game_events_loop(iter(events), ctx, state, meta) - - -class TestRunEventLoop: - """Tests for _run_event_loop.""" - - def test_run_event_loop_zero_iterations(self) -> None: - """Test running event loop with zero iterations.""" - api = MagicMock() - ctx = BotContext(api=api, engine=MagicMock(), bot_version=1) - game_threads: GameThreads = {} - - # Should complete immediately with 0 iterations - _run_event_loop(ctx, game_threads, 0, 0) - - def test_run_event_loop_limited_iterations(self) -> None: - """Test running event loop with limited iterations.""" - api = MagicMock() - api.stream_bot_events.return_value = iter([]) - ctx = BotContext(api=api, engine=MagicMock(), bot_version=1) - game_threads: GameThreads = {} - - with patch( - "python_pkg.lichess_bot.main._safe_event_loop_iteration", return_value=0 - ) as mock_iter: - _run_event_loop(ctx, game_threads, 0, 3) - assert mock_iter.call_count == 3 - - def test_run_event_loop_none_iterations_needs_interrupt(self) -> None: - """Test that None iterations runs until interrupted.""" - api = MagicMock() - ctx = BotContext(api=api, engine=MagicMock(), bot_version=1) - game_threads: GameThreads = {} - - call_count = 0 - - def stop_after_calls(*_args: object, **_kwargs: object) -> int: - nonlocal call_count - call_count += 1 - if call_count >= 5: - raise KeyboardInterrupt - return 0 - - with ( - patch( - "python_pkg.lichess_bot.main._safe_event_loop_iteration", - side_effect=stop_after_calls, - ), - pytest.raises(KeyboardInterrupt), - ): - _run_event_loop(ctx, game_threads, 0, None) - - assert call_count == 5 - - -class TestHandleChallenge: - """Tests for _handle_challenge.""" - - def test_accept_standard_blitz(self) -> None: - """Test accepting standard blitz challenge.""" - api = MagicMock() - challenge: Event = { - "id": "ch1", - "variant": {"key": "standard"}, - "speed": "blitz", - } - _handle_challenge(challenge, api, decline_correspondence=False) - api.accept_challenge.assert_called_once_with("ch1") - - def test_decline_variant(self) -> None: - """Test declining non-standard variant.""" - api = MagicMock() - challenge: Event = { - "id": "ch1", - "variant": {"key": "chess960"}, - "speed": "blitz", - } - _handle_challenge(challenge, api, decline_correspondence=False) - api.decline_challenge.assert_called_once() - - def test_decline_correspondence(self) -> None: - """Test declining correspondence when flag set.""" - api = MagicMock() - challenge: Event = { - "id": "ch1", - "variant": {"key": "standard"}, - "speed": "correspondence", - } - _handle_challenge(challenge, api, decline_correspondence=True) - api.decline_challenge.assert_called_once() - - def test_accept_correspondence_when_allowed(self) -> None: - """Test accepting correspondence when flag not set.""" - api = MagicMock() - challenge: Event = { - "id": "ch1", - "variant": {"key": "standard"}, - "speed": "correspondence", - } - _handle_challenge(challenge, api, decline_correspondence=False) - api.decline_challenge.assert_called_once() # Still declined due to perf_ok - - def test_invalid_variant_data(self) -> None: - """Test handling invalid variant data.""" - api = MagicMock() - challenge: Event = { - "id": "ch1", - "variant": "invalid", - "speed": "blitz", - } - _handle_challenge(challenge, api, decline_correspondence=False) - api.accept_challenge.assert_called_once() - - -class TestProcessBotEvent: - """Tests for _process_bot_event.""" - - def test_process_challenge_event(self) -> None: - """Test processing challenge event.""" - api = MagicMock() - ctx = BotContext(api=api, engine=MagicMock(), bot_version=1) - game_threads: GameThreads = {} - event: Event = { - "type": "challenge", - "challenge": { - "id": "ch1", - "variant": {"key": "standard"}, - "speed": "blitz", - }, - } - _process_bot_event(event, ctx, game_threads) - api.accept_challenge.assert_called_once() - - def test_process_game_start_event(self) -> None: - """Test processing gameStart event.""" - api = MagicMock() - ctx = BotContext(api=api, engine=MagicMock(), bot_version=1) - game_threads: GameThreads = {} - event: Event = {"type": "gameStart", "game": {"id": "game1"}} - - with patch("python_pkg.lichess_bot.main.threading.Thread") as mock_thread_class: - mock_thread = MagicMock() - mock_thread_class.return_value = mock_thread - _process_bot_event(event, ctx, game_threads) - - assert "game1" in game_threads - mock_thread.start.assert_called_once() - - def test_process_game_start_existing_thread(self) -> None: - """Test processing gameStart with existing alive thread.""" - api = MagicMock() - ctx = BotContext(api=api, engine=MagicMock(), bot_version=1) - mock_thread = MagicMock(spec=threading.Thread) - mock_thread.is_alive.return_value = True - game_threads: GameThreads = {"game1": mock_thread} - event: Event = {"type": "gameStart", "game": {"id": "game1"}} - _process_bot_event(event, ctx, game_threads) - # Should not create new thread - assert game_threads["game1"] is mock_thread - - def test_process_game_finish_event(self) -> None: - """Test processing gameFinish event.""" - api = MagicMock() - ctx = BotContext(api=api, engine=MagicMock(), bot_version=1) - game_threads: GameThreads = {} - event: Event = {"type": "gameFinish", "game": {"id": "game1"}} - with patch("python_pkg.lichess_bot.main._logger") as mock_logger: - _process_bot_event(event, ctx, game_threads) - mock_logger.info.assert_called() - - def test_process_game_finish_invalid_data(self) -> None: - """Test processing gameFinish event with non-dict game data.""" - api = MagicMock() - ctx = BotContext(api=api, engine=MagicMock(), bot_version=1) - game_threads: GameThreads = {} - event: Event = {"type": "gameFinish", "game": "not_a_dict"} - with patch("python_pkg.lichess_bot.main._logger") as mock_logger: - _process_bot_event(event, ctx, game_threads) - # Should not log info since game data is invalid - mock_logger.info.assert_not_called() - - def test_process_unknown_event(self) -> None: - """Test processing unknown event.""" - api = MagicMock() - ctx = BotContext(api=api, engine=MagicMock(), bot_version=1) - game_threads: GameThreads = {} - event: Event = {"type": "unknown", "data": "test"} - with patch("python_pkg.lichess_bot.main._logger") as mock_logger: - _process_bot_event(event, ctx, game_threads) - mock_logger.debug.assert_called() - - def test_process_challenge_invalid_data(self) -> None: - """Test processing challenge with invalid data.""" - api = MagicMock() - ctx = BotContext(api=api, engine=MagicMock(), bot_version=1) - game_threads: GameThreads = {} - event: Event = {"type": "challenge", "challenge": "invalid"} - _process_bot_event(event, ctx, game_threads) - api.accept_challenge.assert_not_called() - - def test_process_game_start_invalid_data(self) -> None: - """Test processing gameStart with invalid data.""" - api = MagicMock() - ctx = BotContext(api=api, engine=MagicMock(), bot_version=1) - game_threads: GameThreads = {} - event: Event = {"type": "gameStart", "game": "invalid"} - _process_bot_event(event, ctx, game_threads) - assert len(game_threads) == 0 - - -class TestStreamBotEvents: - """Tests for _stream_bot_events.""" - - def test_stream_bot_events(self) -> None: - """Test streaming bot events.""" - api = MagicMock() - api.stream_events.return_value = iter([{"type": "test"}]) - ctx = BotContext(api=api, engine=MagicMock(), bot_version=1) - events = list(_stream_bot_events(ctx)) - assert len(events) == 1 - - -class TestRunEventLoopIteration: - """Tests for _run_event_loop_iteration.""" - - def test_run_event_loop_iteration(self) -> None: - """Test running event loop iteration.""" - api = MagicMock() - api.stream_events.return_value = iter([{"type": "unknown"}]) - ctx = BotContext(api=api, engine=MagicMock(), bot_version=1) - game_threads: GameThreads = {} - result = _run_event_loop_iteration(ctx, game_threads) - assert result == 0 - - -class TestSafeEventLoopIteration: - """Tests for _safe_event_loop_iteration.""" - - def test_safe_event_loop_iteration_success(self) -> None: - """Test safe event loop iteration success.""" - api = MagicMock() - api.stream_events.return_value = iter([]) - ctx = BotContext(api=api, engine=MagicMock(), bot_version=1) - game_threads: GameThreads = {} - result = _safe_event_loop_iteration(ctx, game_threads, 0) - assert result == 0 - - def test_safe_event_loop_iteration_error(self) -> None: - """Test safe event loop iteration with error.""" - api = MagicMock() - api.stream_events.side_effect = requests.RequestException("error") - ctx = BotContext(api=api, engine=MagicMock(), bot_version=1) - game_threads: GameThreads = {} - with patch("python_pkg.lichess_bot.main.backoff_sleep", return_value=5): - result = _safe_event_loop_iteration(ctx, game_threads, 2) - assert result == 5 - - -class TestRunBot: - """Tests for run_bot.""" - - def test_run_bot_no_token(self) -> None: - """Test run_bot without token raises error.""" - with ( - patch.dict(os.environ, {}, clear=True), - pytest.raises(RuntimeError, match="LICHESS_TOKEN"), - ): - run_bot() - - def test_run_bot_with_token(self) -> None: - """Test run_bot with token starts event loop.""" - - class _StopLoopError(Exception): - """Custom exception to stop the loop.""" - - def stop_loop(*_args: object, **_kwargs: object) -> None: - raise _StopLoopError - - with ( - patch.dict(os.environ, {"LICHESS_TOKEN": "test_token"}), - patch( - "python_pkg.lichess_bot.main.get_and_increment_version", - return_value=1, - ), - patch("python_pkg.lichess_bot.main.LichessAPI"), - patch("python_pkg.lichess_bot.main.RandomEngine"), - patch( - "python_pkg.lichess_bot.main._safe_event_loop_iteration", - side_effect=stop_loop, - ), - pytest.raises(_StopLoopError), - ): - run_bot("DEBUG", decline_correspondence=True) - - -class TestMain: - """Tests for main function.""" - - def test_main_parses_args(self) -> None: - """Test main parses command line arguments.""" - - class _StopExecutionError(Exception): - """Custom exception to stop execution.""" - - with ( - patch( - "sys.argv", - ["main.py", "--log-level", "DEBUG", "--decline-correspondence"], - ), - patch( - "python_pkg.lichess_bot.main.run_bot", side_effect=_StopExecutionError - ) as mock_run_bot, - pytest.raises(_StopExecutionError), - ): - main() - mock_run_bot.assert_called_once_with("DEBUG", decline_correspondence=True) diff --git a/python_pkg/lichess_bot/tests/test_main_analysis.py b/python_pkg/lichess_bot/tests/test_main_analysis.py new file mode 100644 index 0000000..811e847 --- /dev/null +++ b/python_pkg/lichess_bot/tests/test_main_analysis.py @@ -0,0 +1,409 @@ +"""Tests for lichess_bot main module: game events and analysis.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any +from unittest.mock import MagicMock, PropertyMock, patch + +import chess +import pytest + +from python_pkg.lichess_bot.main import ( + BotContext, + GameMeta, + GameState, + _collect_analysis_lines, + _finalize_game, + _insert_analysis_into_log, + _log_analysis_progress, + _process_analysis_output, + _process_game_event, + _run_analysis_subprocess, + _write_pgn_to_log, +) + +if TYPE_CHECKING: + from pathlib import Path + +# Type alias to make mypy happy with test event dicts +Event = dict[str, Any] + + +class TestProcessGameEvent: + """Tests for _process_game_event.""" + + def test_process_game_event_unhandled_type(self) -> None: + """Test processing unhandled event type.""" + ctx = MagicMock() + state = GameState() + meta = GameMeta(game_id="game1", bot_version=1) + event: Event = {"type": "chatLine", "text": "hello"} + result = _process_game_event(event, ctx, state, meta) + assert result is True + + def test_process_game_event_game_full(self) -> None: + """Test processing gameFull event.""" + api = MagicMock() + api.get_my_user_id.return_value = "mybot" + engine = MagicMock() + engine.max_time_sec = 5.0 + engine.choose_move_with_explanation.return_value = ( + chess.Move.from_uci("e2e4"), + "opening", + ) + ctx = BotContext(api=api, engine=engine, bot_version=1) + state = GameState() + meta = GameMeta(game_id="game1", bot_version=1) + event: Event = { + "type": "gameFull", + "state": {"moves": "", "status": "started"}, + "white": {"id": "mybot"}, + "black": {"id": "opp"}, + } + result = _process_game_event(event, ctx, state, meta) + assert result is True + + def test_process_game_event_game_end(self) -> None: + """Test processing game end event.""" + api = MagicMock() + api.get_my_user_id.return_value = "mybot" + engine = MagicMock() + engine.max_time_sec = 5.0 + engine.choose_move_with_explanation.return_value = (None, "no moves") + ctx = BotContext(api=api, engine=engine, bot_version=1) + state = GameState(color="white", last_handled_len=-1) + meta = GameMeta(game_id="game1", bot_version=1) + event: Event = { + "type": "gameState", + "moves": "e2e4 e7e5", + "status": "mate", + } + result = _process_game_event(event, ctx, state, meta) + assert result is False + + def test_process_game_event_game_end_after_move(self) -> None: + """Test game ends with status after handling move. + + This covers the case where _handle_move_if_needed returns True + but status indicates game end. + """ + api = MagicMock() + api.get_my_user_id.return_value = "mybot" + engine = MagicMock() + engine.max_time_sec = 5.0 + engine.choose_move_with_explanation.return_value = ( + chess.Move.from_uci("d2d4"), + "response", + ) + ctx = BotContext(api=api, engine=engine, bot_version=1) + # Black's turn - it's opponent's move, so we don't need to move + state = GameState(color="black", last_handled_len=-1) + meta = GameMeta(game_id="game1", bot_version=1) + event: Event = { + "type": "gameState", + "moves": "e2e4", # One move - now it's black's turn + "status": "resign", # Game ended with resign + } + result = _process_game_event(event, ctx, state, meta) + assert result is False # Game should end + + def test_process_game_event_unchanged_position(self) -> None: + """Test processing event with unchanged position.""" + api = MagicMock() + ctx = BotContext(api=api, engine=MagicMock(), bot_version=1) + state = GameState(last_handled_len=2, color="white") + meta = GameMeta(game_id="game1", bot_version=1) + event: Event = {"type": "gameState", "moves": "e2e4 e7e5"} + result = _process_game_event(event, ctx, state, meta) + assert result is True + + def test_process_game_event_color_unknown(self) -> None: + """Test processing event with unknown color.""" + api = MagicMock() + api.get_my_user_id.return_value = "mybot" + ctx = BotContext(api=api, engine=MagicMock(), bot_version=1) + state = GameState(last_handled_len=-1) + meta = GameMeta(game_id="game1", bot_version=1) + event: Event = {"type": "gameState", "moves": "e2e4"} + result = _process_game_event(event, ctx, state, meta) + assert result is True + assert state.last_handled_len == 1 + + def test_process_game_event_color_unknown_on_gamefull(self) -> None: + """Test processing gameFull event with still unknown color. + + This covers the branch where event_type is gameFull but color + is not determined (e.g., spectator watching game). + """ + api = MagicMock() + # Return a user id that doesn't match either player + api.get_my_user_id.return_value = "spectator" + ctx = BotContext(api=api, engine=MagicMock(), bot_version=1) + state = GameState(last_handled_len=-1) + meta = GameMeta(game_id="game1", bot_version=1) + event: Event = { + "type": "gameFull", + "state": {"moves": "e2e4", "status": "started"}, + "white": {"id": "player1"}, + "black": {"id": "player2"}, + } + result = _process_game_event(event, ctx, state, meta) + assert result is True + # last_handled_len should NOT be updated for gameFull with unknown color + assert state.last_handled_len == -1 + + +class TestWritePgnToLog: + """Tests for _write_pgn_to_log.""" + + def test_write_pgn_to_log(self, tmp_path: Path) -> None: + """Test writing PGN to log file.""" + log_path = tmp_path / "game.log" + log_path.write_text("header\n") + board = chess.Board() + board.push_uci("e2e4") + meta = GameMeta( + game_id="game1", + bot_version=1, + site_url="https://lichess.org/game1", + date_iso="2021.01.01", + white_name="White", + black_name="Black", + ) + _write_pgn_to_log(log_path, board, meta) + content = log_path.read_text() + assert "PGN:" in content + assert "e4" in content + + +class TestRunAnalysisSubprocess: + """Tests for _run_analysis_subprocess.""" + + def test_run_analysis_subprocess_script_not_found(self, tmp_path: Path) -> None: + """Test analysis when script not found.""" + log_path = tmp_path / "game.log" + with patch("python_pkg.lichess_bot.main.Path") as mock_path: + mock_script = MagicMock() + mock_script.is_file.return_value = False + resolve = mock_path.return_value.resolve.return_value + resolve.parent.parent.__truediv__.return_value.__truediv__.return_value = ( + mock_script + ) + result = _run_analysis_subprocess("game1", log_path, 10) + assert result is None + + def test_run_analysis_subprocess_success(self, tmp_path: Path) -> None: + """Test successful analysis subprocess.""" + log_path = tmp_path / "game.log" + log_path.write_text("test") + + mock_proc = MagicMock() + mock_proc.stdout = iter([" 1 e4\n", " 2 e5\n"]) + mock_proc.stderr.read.return_value = "" + mock_proc.wait.return_value = 0 + mock_proc.__enter__ = MagicMock(return_value=mock_proc) + mock_proc.__exit__ = MagicMock(return_value=False) + + with ( + patch("python_pkg.lichess_bot.main.Path") as mock_path, + patch("subprocess.Popen", return_value=mock_proc), + ): + mock_script = MagicMock() + mock_script.is_file.return_value = True + resolve = mock_path.return_value.resolve.return_value + resolve.parent.parent.__truediv__.return_value.__truediv__.return_value = ( + mock_script + ) + result = _run_analysis_subprocess("game1", log_path, 2) + + assert result is not None + + +class TestProcessAnalysisOutput: + """Tests for _process_analysis_output.""" + + def test_process_analysis_output_success(self) -> None: + """Test processing analysis output successfully.""" + mock_proc = MagicMock() + mock_proc.stdout = iter([" 1 e4\n", " 2 e5\n"]) + mock_proc.stderr.read.return_value = "" + mock_proc.wait.return_value = 0 + + result = _process_analysis_output(mock_proc, "game1", 2) + assert result is not None + assert "e4" in result + + def test_process_analysis_output_error_exit(self) -> None: + """Test processing analysis output with error exit.""" + mock_proc = MagicMock() + mock_proc.stdout = iter(["output\n"]) + mock_proc.stderr.read.return_value = "error message" + mock_proc.wait.return_value = 1 + + result = _process_analysis_output(mock_proc, "game1", 1) + assert result is not None + assert "stderr" in result + + def test_process_analysis_output_error_exit_no_stderr(self) -> None: + """Test processing analysis output with error exit but no stderr.""" + mock_proc = MagicMock() + mock_proc.stdout = iter(["output\n"]) + mock_proc.stderr.read.return_value = "" + mock_proc.wait.return_value = 1 + + result = _process_analysis_output(mock_proc, "game1", 1) + assert result is not None + assert "stderr" not in result + + def test_process_analysis_output_none_pipes(self) -> None: + """Test processing analysis output with None pipes.""" + mock_proc = MagicMock() + mock_proc.stdout = None + mock_proc.stderr = None + + with pytest.raises(RuntimeError, match="pipes unexpectedly None"): + _process_analysis_output(mock_proc, "game1", 1) + + +class TestCollectAnalysisLines: + """Tests for _collect_analysis_lines helper.""" + + def test_collect_analysis_lines_empty_iterator(self) -> None: + """Test collecting lines from empty iterator.""" + empty_iter: list[str] = [] + analyzed, lines = _collect_analysis_lines(iter(empty_iter), "game1", 10) + assert analyzed == 0 + assert lines == [] + + def test_collect_analysis_lines_with_content(self) -> None: + """Test collecting lines from iterator with content.""" + content = [" 1 e4\n", " 2 e5\n", "not a ply line\n"] + analyzed, lines = _collect_analysis_lines(iter(content), "game1", 3) + assert analyzed == 2 + assert lines == content + + def test_collect_analysis_lines_full_iteration(self) -> None: + """Test that all lines are collected.""" + content = ["line1\n", " 3 Nf3\n", "line3\n"] + analyzed, lines = _collect_analysis_lines(iter(content), "game1", 1) + assert analyzed == 1 + assert len(lines) == 3 + + +class TestLogAnalysisProgress: + """Tests for _log_analysis_progress.""" + + def test_log_analysis_progress_with_total(self) -> None: + """Test logging progress with known total.""" + with patch("python_pkg.lichess_bot.main._logger") as mock_logger: + _log_analysis_progress("game1", 5, 10) + mock_logger.info.assert_called_once() + call_args = mock_logger.info.call_args[0] + assert "50%" in call_args[0] % call_args[1:] + + def test_log_analysis_progress_zero_total(self) -> None: + """Test logging progress with zero total.""" + with patch("python_pkg.lichess_bot.main._logger") as mock_logger: + _log_analysis_progress("game1", 5, 0) + mock_logger.info.assert_called_once() + call_args = mock_logger.info.call_args[0] + assert "unknown" in call_args[0] + + +class TestInsertAnalysisIntoLog: + """Tests for _insert_analysis_into_log.""" + + def test_insert_analysis_before_pgn(self, tmp_path: Path) -> None: + """Test inserting analysis before PGN section.""" + log_path = tmp_path / "game.log" + log_path.write_text("header\n\nPGN:\n1. e4\n") + meta = GameMeta( + game_id="game1", + bot_version=1, + date_iso="2021.01.01", + white_name="White", + black_name="Black", + ) + _insert_analysis_into_log(log_path, "Analysis here", meta) + content = log_path.read_text() + assert "ANALYSIS:" in content + assert content.index("ANALYSIS:") < content.index("PGN:") + + def test_insert_analysis_at_start(self, tmp_path: Path) -> None: + """Test inserting analysis when PGN at start.""" + log_path = tmp_path / "game.log" + log_path.write_text("PGN:\n1. e4\n") + meta = GameMeta(game_id="game1", bot_version=1) + _insert_analysis_into_log(log_path, "Analysis here", meta) + content = log_path.read_text() + assert "ANALYSIS:" in content + + def test_insert_analysis_no_pgn(self, tmp_path: Path) -> None: + """Test inserting analysis when no PGN section.""" + log_path = tmp_path / "game.log" + log_path.write_text("header\n") + meta = GameMeta(game_id="game1", bot_version=1) + _insert_analysis_into_log(log_path, "Analysis here", meta) + content = log_path.read_text() + assert "ANALYSIS:" in content + + def test_insert_analysis_oserror(self, tmp_path: Path) -> None: + """Test inserting analysis with OSError.""" + log_path = tmp_path / "nonexistent" / "game.log" + meta = GameMeta(game_id="game1", bot_version=1) + # Should not raise, just log debug + _insert_analysis_into_log(log_path, "Analysis", meta) + + +class TestFinalizeGame: + """Tests for _finalize_game.""" + + def test_finalize_game_no_log_path(self) -> None: + """Test finalize game with no log path.""" + state = GameState(log_path=None) + meta = GameMeta(game_id="game1", bot_version=1) + _finalize_game(state, meta) # Should not raise + + def test_finalize_game_write_error(self, tmp_path: Path) -> None: + """Test finalize game with write error.""" + log_path = tmp_path / "game.log" + log_path.write_text("header") + state = GameState(log_path=log_path) + meta = GameMeta(game_id="game1", bot_version=1) + + with patch( + "python_pkg.lichess_bot.main._write_pgn_to_log", + side_effect=OSError("error"), + ): + _finalize_game(state, meta) # Should not raise + + def test_finalize_game_type_error_on_move_stack(self, tmp_path: Path) -> None: + """Test finalize game with TypeError on move_stack.""" + log_path = tmp_path / "game.log" + log_path.write_text("header\n") + state = GameState(log_path=log_path) + meta = GameMeta(game_id="game1", bot_version=1) + + mock_board = MagicMock() + # Use PropertyMock to raise TypeError when move_stack is accessed + type(mock_board).move_stack = PropertyMock(side_effect=TypeError()) + state.board = mock_board + + with patch("python_pkg.lichess_bot.main._write_pgn_to_log"): + _finalize_game(state, meta) # Should not raise + + def test_finalize_game_analysis_error(self, tmp_path: Path) -> None: + """Test finalize game with analysis error.""" + log_path = tmp_path / "game.log" + log_path.write_text("header\n") + state = GameState(log_path=log_path) + meta = GameMeta(game_id="game1", bot_version=1) + + with ( + patch("python_pkg.lichess_bot.main._write_pgn_to_log"), + patch( + "python_pkg.lichess_bot.main._run_analysis_subprocess", + side_effect=OSError("error"), + ), + ): + _finalize_game(state, meta) # Should not raise diff --git a/python_pkg/lichess_bot/tests/test_main_bot_loop.py b/python_pkg/lichess_bot/tests/test_main_bot_loop.py new file mode 100644 index 0000000..e9d38a6 --- /dev/null +++ b/python_pkg/lichess_bot/tests/test_main_bot_loop.py @@ -0,0 +1,474 @@ +"""Tests for lichess_bot main module: bot event loop.""" + +from __future__ import annotations + +import os +import threading +from typing import TYPE_CHECKING, Any +from unittest.mock import MagicMock, patch + +import chess +import pytest +import requests + +from python_pkg.lichess_bot.main import ( + BotContext, + GameMeta, + GameState, + _handle_challenge, + _handle_game, + _process_bot_event, + _process_game_events_loop, + _run_event_loop, + _run_event_loop_iteration, + _safe_event_loop_iteration, + _stream_bot_events, + main, + run_bot, +) + +if TYPE_CHECKING: + from pathlib import Path + +# Type aliases to make mypy happy with test event dicts +Event = dict[str, Any] +GameThreads = dict[str, threading.Thread] + + +class TestHandleGame: + """Tests for _handle_game.""" + + def test_handle_game_success(self, tmp_path: Path) -> None: + """Test handling a game successfully.""" + api = MagicMock() + api.get_my_user_id.return_value = "mybot" + api.stream_game_events.return_value = iter( + [ + { + "type": "gameFull", + "state": {"moves": "", "status": "started"}, + "white": {"id": "mybot"}, + "black": {"id": "opp"}, + }, + {"type": "gameState", "moves": "e2e4", "status": "mate"}, + ] + ) + engine = MagicMock() + engine.max_time_sec = 5.0 + engine.choose_move_with_explanation.return_value = (None, "no moves") + ctx = BotContext(api=api, engine=engine, bot_version=1) + + with ( + patch("python_pkg.lichess_bot.main.Path.cwd", return_value=tmp_path), + patch( + "python_pkg.lichess_bot.main._run_analysis_subprocess", + return_value=None, + ), + ): + _handle_game("game1", ctx, None) + + def test_handle_game_request_error(self, tmp_path: Path) -> None: + """Test handling a game with request error.""" + api = MagicMock() + api.stream_game_events.side_effect = requests.RequestException("error") + ctx = BotContext(api=api, engine=MagicMock(), bot_version=1) + + with ( + patch("python_pkg.lichess_bot.main.Path.cwd", return_value=tmp_path), + patch( + "python_pkg.lichess_bot.main._run_analysis_subprocess", + return_value=None, + ), + ): + _handle_game("game1", ctx, None) # Should not raise + + def test_handle_game_skips_chat_events(self, tmp_path: Path) -> None: + """Test handling a game skips chat events.""" + api = MagicMock() + api.stream_game_events.return_value = iter( + [ + {"type": "chatLine", "text": "hello"}, + {"type": "opponentGone", "gone": True}, + ] + ) + ctx = BotContext(api=api, engine=MagicMock(), bot_version=1) + + with ( + patch("python_pkg.lichess_bot.main.Path.cwd", return_value=tmp_path), + patch( + "python_pkg.lichess_bot.main._run_analysis_subprocess", + return_value=None, + ), + ): + _handle_game("game1", ctx, None) + + +class TestProcessGameEventsLoop: + """Tests for _process_game_events_loop.""" + + def test_empty_events_iterator(self) -> None: + """Test processing empty events iterator.""" + api = MagicMock() + ctx = BotContext(api=api, engine=MagicMock(), bot_version=1) + state = GameState(color="white") + meta = GameMeta(game_id="game1", bot_version=1) + + empty_iter: list[Event] = [] + # Should complete without error when iterator is empty + _process_game_events_loop(iter(empty_iter), ctx, state, meta) + + def test_processes_all_events(self) -> None: + """Test that all events are processed until break condition.""" + api = MagicMock() + engine = MagicMock() + engine.max_time_sec = 5.0 + engine.choose_move_with_explanation.return_value = (None, "no moves") + ctx = BotContext(api=api, engine=engine, bot_version=1) + state = GameState(color="white") + meta = GameMeta(game_id="game1", bot_version=1) + + events: list[Event] = [ + {"type": "chatLine", "text": "hello"}, # skipped + {"type": "gameState", "moves": "e2e4", "status": "resign"}, # game end + ] + _process_game_events_loop(iter(events), ctx, state, meta) + + def test_processes_multiple_game_events(self) -> None: + """Test processing multiple game events that continue the game.""" + api = MagicMock() + engine = MagicMock() + engine.max_time_sec = 5.0 + engine.choose_move_with_explanation.return_value = ( + chess.Move.from_uci("e2e4"), + "e4", + ) + api.make_move.return_value = None + ctx = BotContext(api=api, engine=engine, bot_version=1) + state = GameState(color="white") + state.board = chess.Board() + meta = GameMeta(game_id="game1", bot_version=1) + + events: list[Event] = [ + # First event - game state, game continues + {"type": "gameState", "moves": "", "status": "started"}, + # Second event - opponent moves, game continues + {"type": "gameState", "moves": "e2e4 e7e5", "status": "started"}, + # Third event - game ends + {"type": "gameState", "moves": "e2e4 e7e5", "status": "mate"}, + ] + _process_game_events_loop(iter(events), ctx, state, meta) + + +class TestRunEventLoop: + """Tests for _run_event_loop.""" + + def test_run_event_loop_zero_iterations(self) -> None: + """Test running event loop with zero iterations.""" + api = MagicMock() + ctx = BotContext(api=api, engine=MagicMock(), bot_version=1) + game_threads: GameThreads = {} + + # Should complete immediately with 0 iterations + _run_event_loop(ctx, game_threads, 0, 0) + + def test_run_event_loop_limited_iterations(self) -> None: + """Test running event loop with limited iterations.""" + api = MagicMock() + api.stream_bot_events.return_value = iter([]) + ctx = BotContext(api=api, engine=MagicMock(), bot_version=1) + game_threads: GameThreads = {} + + with patch( + "python_pkg.lichess_bot.main._safe_event_loop_iteration", return_value=0 + ) as mock_iter: + _run_event_loop(ctx, game_threads, 0, 3) + assert mock_iter.call_count == 3 + + def test_run_event_loop_none_iterations_needs_interrupt(self) -> None: + """Test that None iterations runs until interrupted.""" + api = MagicMock() + ctx = BotContext(api=api, engine=MagicMock(), bot_version=1) + game_threads: GameThreads = {} + + call_count = 0 + + def stop_after_calls(*_args: object, **_kwargs: object) -> int: + nonlocal call_count + call_count += 1 + if call_count >= 5: + raise KeyboardInterrupt + return 0 + + with ( + patch( + "python_pkg.lichess_bot.main._safe_event_loop_iteration", + side_effect=stop_after_calls, + ), + pytest.raises(KeyboardInterrupt), + ): + _run_event_loop(ctx, game_threads, 0, None) + + assert call_count == 5 + + +class TestHandleChallenge: + """Tests for _handle_challenge.""" + + def test_accept_standard_blitz(self) -> None: + """Test accepting standard blitz challenge.""" + api = MagicMock() + challenge: Event = { + "id": "ch1", + "variant": {"key": "standard"}, + "speed": "blitz", + } + _handle_challenge(challenge, api, decline_correspondence=False) + api.accept_challenge.assert_called_once_with("ch1") + + def test_decline_variant(self) -> None: + """Test declining non-standard variant.""" + api = MagicMock() + challenge: Event = { + "id": "ch1", + "variant": {"key": "chess960"}, + "speed": "blitz", + } + _handle_challenge(challenge, api, decline_correspondence=False) + api.decline_challenge.assert_called_once() + + def test_decline_correspondence(self) -> None: + """Test declining correspondence when flag set.""" + api = MagicMock() + challenge: Event = { + "id": "ch1", + "variant": {"key": "standard"}, + "speed": "correspondence", + } + _handle_challenge(challenge, api, decline_correspondence=True) + api.decline_challenge.assert_called_once() + + def test_accept_correspondence_when_allowed(self) -> None: + """Test accepting correspondence when flag not set.""" + api = MagicMock() + challenge: Event = { + "id": "ch1", + "variant": {"key": "standard"}, + "speed": "correspondence", + } + _handle_challenge(challenge, api, decline_correspondence=False) + api.decline_challenge.assert_called_once() # Still declined due to perf_ok + + def test_invalid_variant_data(self) -> None: + """Test handling invalid variant data.""" + api = MagicMock() + challenge: Event = { + "id": "ch1", + "variant": "invalid", + "speed": "blitz", + } + _handle_challenge(challenge, api, decline_correspondence=False) + api.accept_challenge.assert_called_once() + + +class TestProcessBotEvent: + """Tests for _process_bot_event.""" + + def test_process_challenge_event(self) -> None: + """Test processing challenge event.""" + api = MagicMock() + ctx = BotContext(api=api, engine=MagicMock(), bot_version=1) + game_threads: GameThreads = {} + event: Event = { + "type": "challenge", + "challenge": { + "id": "ch1", + "variant": {"key": "standard"}, + "speed": "blitz", + }, + } + _process_bot_event(event, ctx, game_threads) + api.accept_challenge.assert_called_once() + + def test_process_game_start_event(self) -> None: + """Test processing gameStart event.""" + api = MagicMock() + ctx = BotContext(api=api, engine=MagicMock(), bot_version=1) + game_threads: GameThreads = {} + event: Event = {"type": "gameStart", "game": {"id": "game1"}} + + with patch("python_pkg.lichess_bot.main.threading.Thread") as mock_thread_class: + mock_thread = MagicMock() + mock_thread_class.return_value = mock_thread + _process_bot_event(event, ctx, game_threads) + + assert "game1" in game_threads + mock_thread.start.assert_called_once() + + def test_process_game_start_existing_thread(self) -> None: + """Test processing gameStart with existing alive thread.""" + api = MagicMock() + ctx = BotContext(api=api, engine=MagicMock(), bot_version=1) + mock_thread = MagicMock(spec=threading.Thread) + mock_thread.is_alive.return_value = True + game_threads: GameThreads = {"game1": mock_thread} + event: Event = {"type": "gameStart", "game": {"id": "game1"}} + _process_bot_event(event, ctx, game_threads) + # Should not create new thread + assert game_threads["game1"] is mock_thread + + def test_process_game_finish_event(self) -> None: + """Test processing gameFinish event.""" + api = MagicMock() + ctx = BotContext(api=api, engine=MagicMock(), bot_version=1) + game_threads: GameThreads = {} + event: Event = {"type": "gameFinish", "game": {"id": "game1"}} + with patch("python_pkg.lichess_bot.main._logger") as mock_logger: + _process_bot_event(event, ctx, game_threads) + mock_logger.info.assert_called() + + def test_process_game_finish_invalid_data(self) -> None: + """Test processing gameFinish event with non-dict game data.""" + api = MagicMock() + ctx = BotContext(api=api, engine=MagicMock(), bot_version=1) + game_threads: GameThreads = {} + event: Event = {"type": "gameFinish", "game": "not_a_dict"} + with patch("python_pkg.lichess_bot.main._logger") as mock_logger: + _process_bot_event(event, ctx, game_threads) + # Should not log info since game data is invalid + mock_logger.info.assert_not_called() + + def test_process_unknown_event(self) -> None: + """Test processing unknown event.""" + api = MagicMock() + ctx = BotContext(api=api, engine=MagicMock(), bot_version=1) + game_threads: GameThreads = {} + event: Event = {"type": "unknown", "data": "test"} + with patch("python_pkg.lichess_bot.main._logger") as mock_logger: + _process_bot_event(event, ctx, game_threads) + mock_logger.debug.assert_called() + + def test_process_challenge_invalid_data(self) -> None: + """Test processing challenge with invalid data.""" + api = MagicMock() + ctx = BotContext(api=api, engine=MagicMock(), bot_version=1) + game_threads: GameThreads = {} + event: Event = {"type": "challenge", "challenge": "invalid"} + _process_bot_event(event, ctx, game_threads) + api.accept_challenge.assert_not_called() + + def test_process_game_start_invalid_data(self) -> None: + """Test processing gameStart with invalid data.""" + api = MagicMock() + ctx = BotContext(api=api, engine=MagicMock(), bot_version=1) + game_threads: GameThreads = {} + event: Event = {"type": "gameStart", "game": "invalid"} + _process_bot_event(event, ctx, game_threads) + assert len(game_threads) == 0 + + +class TestStreamBotEvents: + """Tests for _stream_bot_events.""" + + def test_stream_bot_events(self) -> None: + """Test streaming bot events.""" + api = MagicMock() + api.stream_events.return_value = iter([{"type": "test"}]) + ctx = BotContext(api=api, engine=MagicMock(), bot_version=1) + events = list(_stream_bot_events(ctx)) + assert len(events) == 1 + + +class TestRunEventLoopIteration: + """Tests for _run_event_loop_iteration.""" + + def test_run_event_loop_iteration(self) -> None: + """Test running event loop iteration.""" + api = MagicMock() + api.stream_events.return_value = iter([{"type": "unknown"}]) + ctx = BotContext(api=api, engine=MagicMock(), bot_version=1) + game_threads: GameThreads = {} + result = _run_event_loop_iteration(ctx, game_threads) + assert result == 0 + + +class TestSafeEventLoopIteration: + """Tests for _safe_event_loop_iteration.""" + + def test_safe_event_loop_iteration_success(self) -> None: + """Test safe event loop iteration success.""" + api = MagicMock() + api.stream_events.return_value = iter([]) + ctx = BotContext(api=api, engine=MagicMock(), bot_version=1) + game_threads: GameThreads = {} + result = _safe_event_loop_iteration(ctx, game_threads, 0) + assert result == 0 + + def test_safe_event_loop_iteration_error(self) -> None: + """Test safe event loop iteration with error.""" + api = MagicMock() + api.stream_events.side_effect = requests.RequestException("error") + ctx = BotContext(api=api, engine=MagicMock(), bot_version=1) + game_threads: GameThreads = {} + with patch("python_pkg.lichess_bot.main.backoff_sleep", return_value=5): + result = _safe_event_loop_iteration(ctx, game_threads, 2) + assert result == 5 + + +class TestRunBot: + """Tests for run_bot.""" + + def test_run_bot_no_token(self) -> None: + """Test run_bot without token raises error.""" + with ( + patch.dict(os.environ, {}, clear=True), + pytest.raises(RuntimeError, match="LICHESS_TOKEN"), + ): + run_bot() + + def test_run_bot_with_token(self) -> None: + """Test run_bot with token starts event loop.""" + + class _StopLoopError(Exception): + """Custom exception to stop the loop.""" + + def stop_loop(*_args: object, **_kwargs: object) -> None: + raise _StopLoopError + + with ( + patch.dict(os.environ, {"LICHESS_TOKEN": "test_token"}), + patch( + "python_pkg.lichess_bot.main.get_and_increment_version", + return_value=1, + ), + patch("python_pkg.lichess_bot.main.LichessAPI"), + patch("python_pkg.lichess_bot.main.RandomEngine"), + patch( + "python_pkg.lichess_bot.main._safe_event_loop_iteration", + side_effect=stop_loop, + ), + pytest.raises(_StopLoopError), + ): + run_bot("DEBUG", decline_correspondence=True) + + +class TestMain: + """Tests for main function.""" + + def test_main_parses_args(self) -> None: + """Test main parses command line arguments.""" + + class _StopExecutionError(Exception): + """Custom exception to stop execution.""" + + with ( + patch( + "sys.argv", + ["main.py", "--log-level", "DEBUG", "--decline-correspondence"], + ), + patch( + "python_pkg.lichess_bot.main.run_bot", side_effect=_StopExecutionError + ) as mock_run_bot, + pytest.raises(_StopExecutionError), + ): + main() + mock_run_bot.assert_called_once_with("DEBUG", decline_correspondence=True) diff --git a/python_pkg/lichess_bot/tests/test_main_game_state.py b/python_pkg/lichess_bot/tests/test_main_game_state.py new file mode 100644 index 0000000..b090492 --- /dev/null +++ b/python_pkg/lichess_bot/tests/test_main_game_state.py @@ -0,0 +1,403 @@ +"""Tests for lichess_bot main module: game state helpers.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any +from unittest.mock import MagicMock, patch + +import chess +import requests + +from python_pkg.lichess_bot.main import ( + BotContext, + GameMeta, + GameState, + _apply_move_to_board, + _attempt_move, + _calculate_time_budget, + _extract_game_full_data, + _extract_game_state_data, + _extract_player_info, + _handle_move_if_needed, + _init_game_log, + _is_my_turn, + _log_move_to_file, + _rebuild_board_from_moves, + _update_clocks_from_state, +) + +if TYPE_CHECKING: + from pathlib import Path + +# Type alias to make mypy happy with test event dicts +Event = dict[str, Any] + + +class TestApplyMoveToBoard: + """Tests for _apply_move_to_board.""" + + def test_apply_valid_move(self) -> None: + """Test applying a valid move.""" + board = chess.Board() + _apply_move_to_board(board, "e2e4", "game1") + assert board.fen() != chess.STARTING_FEN + + def test_apply_invalid_move(self) -> None: + """Test applying an invalid move logs debug.""" + board = chess.Board() + with patch("python_pkg.lichess_bot.main._logger") as mock_logger: + _apply_move_to_board(board, "invalid", "game1") + mock_logger.debug.assert_called_once() + + +class TestInitGameLog: + """Tests for _init_game_log.""" + + def test_init_game_log_success(self, tmp_path: Path) -> None: + """Test successful log initialization.""" + with patch("python_pkg.lichess_bot.main.Path.cwd", return_value=tmp_path): + result = _init_game_log("game123", 42) + assert result is not None + assert result.exists() + content = result.read_text() + assert "game game123 started" in content + assert "bot_version v42" in content + + def test_init_game_log_oserror(self) -> None: + """Test log initialization with OSError.""" + with patch("python_pkg.lichess_bot.main.Path.cwd") as mock_cwd: + mock_path = MagicMock() + mock_path.__truediv__ = MagicMock(return_value=mock_path) + mock_path.open.side_effect = OSError("Permission denied") + mock_cwd.return_value = mock_path + result = _init_game_log("game123", 42) + assert result is None + + +class TestUpdateClocksFromState: + """Tests for _update_clocks_from_state.""" + + def test_update_clocks_white(self) -> None: + """Test clock update when playing as white.""" + state = GameState(color="white") + state_data: Event = {"wtime": 60000, "btime": 55000, "winc": 1000} + _update_clocks_from_state(state_data, state) + assert state.my_ms == 60000 + assert state.opp_ms == 55000 + assert state.inc_ms == 1000 + + def test_update_clocks_black(self) -> None: + """Test clock update when playing as black.""" + state = GameState(color="black") + state_data: Event = {"wtime": 60000, "btime": 55000, "binc": 2000} + _update_clocks_from_state(state_data, state) + assert state.my_ms == 55000 + assert state.opp_ms == 60000 + assert state.inc_ms == 2000 + + def test_update_clocks_float_values(self) -> None: + """Test clock update with float values.""" + state = GameState(color="white") + state_data: Event = {"wtime": 60000.5, "btime": 55000.5} + _update_clocks_from_state(state_data, state) + assert state.my_ms == 60000 + assert state.opp_ms == 55000 + + def test_update_clocks_none_values(self) -> None: + """Test clock update with None values.""" + state = GameState(color="white") + state_data: Event = {"wtime": None, "btime": None} + _update_clocks_from_state(state_data, state) + assert state.my_ms is None + assert state.opp_ms is None + + +class TestExtractPlayerInfo: + """Tests for _extract_player_info.""" + + def test_extract_player_info_white(self) -> None: + """Test extracting player info when bot is white.""" + api = MagicMock() + api.get_my_user_id.return_value = "mybot" + state = GameState() + meta = GameMeta(game_id="game1", bot_version=1) + event: Event = { + "white": {"id": "mybot", "name": "MyBot"}, + "black": {"id": "opp", "name": "Opponent"}, + } + _extract_player_info(event, state, meta, api) + assert state.color == "white" + assert meta.white_name == "MyBot" + assert meta.black_name == "Opponent" + + def test_extract_player_info_black(self) -> None: + """Test extracting player info when bot is black.""" + api = MagicMock() + api.get_my_user_id.return_value = "mybot" + state = GameState() + meta = GameMeta(game_id="game1", bot_version=1) + event: Event = { + "white": {"id": "opp", "name": "Opponent"}, + "black": {"id": "mybot", "name": "MyBot"}, + } + _extract_player_info(event, state, meta, api) + assert state.color == "black" + + def test_extract_player_info_invalid_data(self) -> None: + """Test extracting player info with invalid data.""" + api = MagicMock() + state = GameState() + meta = GameMeta(game_id="game1", bot_version=1) + event: Event = {"white": "invalid", "black": "invalid"} + _extract_player_info(event, state, meta, api) + assert state.color is None + + def test_extract_player_info_missing_name(self) -> None: + """Test extracting player info with missing name uses id.""" + api = MagicMock() + api.get_my_user_id.return_value = "mybot" + state = GameState() + meta = GameMeta(game_id="game1", bot_version=1) + event: Event = { + "white": {"id": "mybot"}, + "black": {"id": "opponent"}, + } + _extract_player_info(event, state, meta, api) + assert meta.white_name == "mybot" + assert meta.black_name == "opponent" + + +class TestExtractGameFullData: + """Tests for _extract_game_full_data.""" + + def test_extract_game_full_data(self) -> None: + """Test extracting gameFull data.""" + api = MagicMock() + api.get_my_user_id.return_value = "mybot" + state = GameState() + meta = GameMeta(game_id="game1", bot_version=1) + event: Event = { + "state": {"moves": "e2e4 e7e5", "status": "started", "wtime": 60000}, + "white": {"id": "mybot"}, + "black": {"id": "opp"}, + "createdAt": 1609459200000, # 2021-01-01 + } + moves, status = _extract_game_full_data(event, state, meta, api) + assert moves == "e2e4 e7e5" + assert status == "started" + assert meta.site_url == "https://lichess.org/game1" + assert meta.date_iso == "2021.01.01" + + def test_extract_game_full_data_invalid_state(self) -> None: + """Test extracting gameFull data with invalid state.""" + api = MagicMock() + state = GameState() + meta = GameMeta(game_id="game1", bot_version=1) + event: Event = {"state": "invalid"} + moves, status = _extract_game_full_data(event, state, meta, api) + assert moves == "" + assert status is None + + +class TestExtractGameStateData: + """Tests for _extract_game_state_data.""" + + def test_extract_game_state_as_white(self) -> None: + """Test extracting gameState data as white.""" + state = GameState(color="white", my_ms=60000) + event: Event = { + "moves": "e2e4", + "status": "started", + "wtime": 59000, + "btime": 60000, + } + moves, status = _extract_game_state_data(event, state) + assert moves == "e2e4" + assert status == "started" + assert state.my_ms == 59000 + assert state.opp_ms == 60000 + + def test_extract_game_state_as_black(self) -> None: + """Test extracting gameState data as black.""" + state = GameState(color="black") + event: Event = { + "moves": "e2e4 e7e5", + "wtime": 60000, + "btime": 59000, + "binc": 1000, + } + moves, __status = _extract_game_state_data(event, state) + assert moves == "e2e4 e7e5" + assert state.my_ms == 59000 + assert state.opp_ms == 60000 + assert state.inc_ms == 1000 + + +class TestCalculateTimeBudget: + """Tests for _calculate_time_budget.""" + + def test_calculate_time_budget_normal(self) -> None: + """Test time budget calculation.""" + state = GameState(my_ms=60000, inc_ms=1000) + board = chess.Board() + budget = _calculate_time_budget(state, board, 10.0) + assert 0.05 <= budget <= 10.0 + + def test_calculate_time_budget_low_time(self) -> None: + """Test time budget with low time.""" + state = GameState(my_ms=1000, inc_ms=0) + board = chess.Board() + budget = _calculate_time_budget(state, board, 10.0) + assert budget >= 0.05 + + +class TestLogMoveToFile: + """Tests for _log_move_to_file.""" + + def test_log_move_to_file(self, tmp_path: Path) -> None: + """Test logging a move to file.""" + log_path = tmp_path / "game.log" + log_path.write_text("header\n") + move = chess.Move.from_uci("e2e4") + _log_move_to_file(log_path, 1, move, "best move") + content = log_path.read_text() + assert "ply 1: e2e4" in content + assert "best move" in content + + def test_log_move_to_file_none_path(self) -> None: + """Test logging with None path does nothing.""" + move = chess.Move.from_uci("e2e4") + _log_move_to_file(None, 1, move, "reason") # Should not raise + + +class TestAttemptMove: + """Tests for _attempt_move.""" + + def test_attempt_move_success(self) -> None: + """Test successful move attempt.""" + api = MagicMock() + engine = MagicMock() + engine.max_time_sec = 5.0 + engine.choose_move_with_explanation.return_value = ( + chess.Move.from_uci("e2e4"), + "opening", + ) + ctx = BotContext(api=api, engine=engine, bot_version=1) + state = GameState(my_ms=60000) + meta = GameMeta(game_id="game1", bot_version=1) + board = chess.Board() + + result = _attempt_move(ctx, state, meta, board) + assert result is True + api.make_move.assert_called_once() + + def test_attempt_move_no_moves(self) -> None: + """Test move attempt with no legal moves.""" + api = MagicMock() + engine = MagicMock() + engine.max_time_sec = 5.0 + engine.choose_move_with_explanation.return_value = (None, "no moves") + ctx = BotContext(api=api, engine=engine, bot_version=1) + state = GameState() + meta = GameMeta(game_id="game1", bot_version=1) + board = chess.Board() + + result = _attempt_move(ctx, state, meta, board) + assert result is False + + def test_attempt_move_illegal(self) -> None: + """Test move attempt with illegal move.""" + api = MagicMock() + engine = MagicMock() + engine.max_time_sec = 5.0 + # Return a move that's not legal (e.g., random square move) + engine.choose_move_with_explanation.return_value = ( + chess.Move.from_uci("a1a8"), + "bad", + ) + ctx = BotContext(api=api, engine=engine, bot_version=1) + state = GameState(my_ms=60000) + meta = GameMeta(game_id="game1", bot_version=1) + board = chess.Board() + + result = _attempt_move(ctx, state, meta, board) + assert result is True + api.make_move.assert_not_called() + + def test_attempt_move_request_error(self) -> None: + """Test move attempt with request error.""" + api = MagicMock() + api.make_move.side_effect = requests.RequestException("Network error") + engine = MagicMock() + engine.max_time_sec = 5.0 + engine.choose_move_with_explanation.return_value = ( + chess.Move.from_uci("e2e4"), + "opening", + ) + ctx = BotContext(api=api, engine=engine, bot_version=1) + state = GameState(my_ms=60000) + meta = GameMeta(game_id="game1", bot_version=1) + board = chess.Board() + + result = _attempt_move(ctx, state, meta, board) + assert result is True # Still returns True + + +class TestIsMyTurn: + """Tests for _is_my_turn.""" + + def test_is_my_turn_white_to_move(self) -> None: + """Test checking turn when white to move.""" + board = chess.Board() # White to move + assert _is_my_turn(board, "white") is True + assert _is_my_turn(board, "black") is False + + def test_is_my_turn_black_to_move(self) -> None: + """Test checking turn when black to move.""" + board = chess.Board() + board.push_uci("e2e4") # Black to move + assert _is_my_turn(board, "white") is False + assert _is_my_turn(board, "black") is True + + +class TestRebuildBoardFromMoves: + """Tests for _rebuild_board_from_moves.""" + + def test_rebuild_board_from_moves(self) -> None: + """Test rebuilding board from moves list.""" + moves_list = ["e2e4", "e7e5", "g1f3"] + board = _rebuild_board_from_moves(moves_list, "game1") + assert len(board.move_stack) == 3 + + +class TestHandleMoveIfNeeded: + """Tests for _handle_move_if_needed.""" + + def test_handle_move_game_state_my_turn(self) -> None: + """Test handling move on gameState when my turn.""" + api = MagicMock() + engine = MagicMock() + engine.max_time_sec = 5.0 + engine.choose_move_with_explanation.return_value = ( + chess.Move.from_uci("e2e4"), + "opening", + ) + ctx = BotContext(api=api, engine=engine, bot_version=1) + state = GameState(color="white", my_ms=60000, board=chess.Board()) + meta = GameMeta(game_id="game1", bot_version=1) + + result = _handle_move_if_needed(ctx, state, meta, "gameState", 0) + assert result is True + + def test_handle_move_game_full_with_moves(self) -> None: + """Test handling move on gameFull with existing moves (opponent's turn).""" + api = MagicMock() + engine = MagicMock() + ctx = BotContext(api=api, engine=engine, bot_version=1) + state = GameState(color="white", my_ms=60000, board=chess.Board()) + meta = GameMeta(game_id="game1", bot_version=1) + + # gameFull with moves - don't move + result = _handle_move_if_needed(ctx, state, meta, "gameFull", 1) + assert result is True + engine.choose_move_with_explanation.assert_not_called() diff --git a/python_pkg/praca_magisterska_video/_q23_classical.py b/python_pkg/praca_magisterska_video/_q23_classical.py new file mode 100644 index 0000000..acf5b07 --- /dev/null +++ b/python_pkg/praca_magisterska_video/_q23_classical.py @@ -0,0 +1,445 @@ +"""Classical segmentation methods: concept, thresholding, region growing, watershed.""" + +from __future__ import annotations + +from moviepy import ( + CompositeVideoClip, + VideoClip, +) +from moviepy.video.fx import FadeIn, FadeOut +import numpy as np + +from python_pkg.praca_magisterska_video._q23_helpers import ( + BG_COLOR, + FONT_B, + FONT_R, + FPS, + STEP_DUR, + H, + W, + _tc, +) + + +# ── Segmentation concept ───────────────────────────────────────── +def _segmentation_concept() -> list[CompositeVideoClip]: + """Show what segmentation is: pixel-level labeling.""" + slides = [] + + # Synthetic image: grid of colored pixels + def make_image_frame(_t: float) -> np.ndarray: + frame = np.zeros((H, W, 3), dtype=np.uint8) + frame[:] = BG_COLOR + + # Draw a small "image" grid + grid_x, grid_y = 100, 150 + cell = 40 + # Sky (top rows) + colors_map = [ + [(135, 206, 235)] * 8, # sky + [(135, 206, 235)] * 5 + [(34, 139, 34)] * 3, # sky + tree + [(34, 139, 34)] * 3 + + [(128, 128, 128)] * 3 + + [(34, 139, 34)] * 2, # tree+road+tree + [(128, 128, 128)] * 3 + + [(200, 50, 50)] * 2 + + [(128, 128, 128)] * 3, # road+car+road + ] + labels_map = [ + ["niebo"] * 8, + ["niebo"] * 5 + ["drzewo"] * 3, + ["drzewo"] * 3 + ["droga"] * 3 + ["drzewo"] * 2, + ["droga"] * 3 + ["samochód"] * 2 + ["droga"] * 3, + ] + label_colors = { + "niebo": (100, 180, 255), + "drzewo": (50, 200, 50), + "droga": (180, 180, 180), + "samochód": (255, 80, 80), + } + + for r, row in enumerate(colors_map): + for c, col in enumerate(row): + y = grid_y + r * cell + x = grid_x + c * cell + frame[y : y + cell - 2, x : x + cell - 2] = col + + # Draw segmentation map on the right + seg_x = 600 + for r, row in enumerate(labels_map): + for c, lab in enumerate(row): + y = grid_y + r * cell + x = seg_x + c * cell + frame[y : y + cell - 2, x : x + cell - 2] = label_colors[lab] + + return frame + + image_clip = VideoClip(make_image_frame, duration=STEP_DUR).with_fps(FPS) + labels_text = [ + ("Obraz wejściowy", 22, "white", FONT_B, (170, 100)), + ("Mapa segmentacji", 22, "white", FONT_B, (660, 100)), + ("→", 50, "#FFE082", FONT_B, (450, 250)), + ("Każdy piksel → etykieta klasy", 20, "#B0BEC5", FONT_R, (100, 420)), + ("niebo | drzewo | droga | samochód", 18, "#90CAF9", FONT_R, (600, 420)), + ("Segmentacja = klasyfikacja per-piksel", 24, "#FFE082", FONT_B, (100, 500)), + ( + "Semantic: klasy bez instancji | Instance: " + "rozróżnia obiekty | Panoptic: oba", + 16, + "#78909C", + FONT_R, + (100, 560), + ), + ] + clips: list[VideoClip] = [image_clip] + for text, fs, color, font, pos in labels_text: + tc = ( + _tc(text=text, font_size=fs, color=color, font=font) + .with_duration(STEP_DUR) + .with_position(pos) + ) + clips.append(tc) + + slides.append( + CompositeVideoClip(clips, size=(W, H)).with_effects([FadeIn(0.3), FadeOut(0.3)]) + ) + return slides + + +# ── Thresholding / Otsu ─────────────────────────────────────────── +def _thresholding_demo() -> list[CompositeVideoClip]: + """Animate thresholding and Otsu concept.""" + slides = [] + + # Show histogram & threshold + def make_threshold_frame(t: float) -> np.ndarray: + frame = np.zeros((H, W, 3), dtype=np.uint8) + frame[:] = BG_COLOR + + # Draw bimodal histogram bars + bar_start_x = 80 + bar_y = 500 + bar_w = 4 + + for i in range(256): + # Bimodal: peaks at 60 and 190 + h1 = 200 * np.exp(-((i - 60) ** 2) / (2 * 20**2)) + h2 = 150 * np.exp(-((i - 190) ** 2) / (2 * 25**2)) + bar_h = int(h1 + h2) + x = bar_start_x + i * bar_w + if x + bar_w < W: + frame[bar_y - bar_h : bar_y, x : x + bar_w - 1] = (150, 150, 170) + + # Animated threshold line + threshold = int(60 + (190 - 60) * min(t / (STEP_DUR * 0.7), 1.0)) + tx = bar_start_x + threshold * bar_w + if tx < W: + frame[bar_y - 250 : bar_y + 10, tx : tx + 3] = (255, 80, 80) + + # Color the two sides + for i in range(threshold): + x = bar_start_x + i * bar_w + h1 = 200 * np.exp(-((i - 60) ** 2) / (2 * 20**2)) + h2 = 150 * np.exp(-((i - 190) ** 2) / (2 * 25**2)) + bar_h = int(h1 + h2) + if x + bar_w < W and bar_h > 0: + frame[bar_y - bar_h : bar_y, x : x + bar_w - 1] = (70, 130, 200) + + for i in range(threshold, 256): + x = bar_start_x + i * bar_w + h1 = 200 * np.exp(-((i - 60) ** 2) / (2 * 20**2)) + h2 = 150 * np.exp(-((i - 190) ** 2) / (2 * 25**2)) + bar_h = int(h1 + h2) + if x + bar_w < W and bar_h > 0: + frame[bar_y - bar_h : bar_y, x : x + bar_w - 1] = (200, 100, 80) + + return frame + + hist_clip = VideoClip(make_threshold_frame, duration=STEP_DUR).with_fps(FPS) + text_clips: list[VideoClip] = [hist_clip] + labels = [ + ("Progowanie (Thresholding) z metodą Otsu", 28, "#FFE082", FONT_B, (80, 30)), + ( + "Histogram jasności pikseli — dwumodalny (bimodal)", + 20, + "#B0BEC5", + FONT_R, + (80, 80), + ), + ("Garb 1: piksele obiektu (ciemne ~60)", 16, "#64B5F6", FONT_R, (80, 120)), + ("Garb 2: piksele tła (jasne ~190)", 16, "#EF9A9A", FONT_R, (80, 150)), + ( + "Próg T (czerwona linia) dzieli piksele na 2 klasy", + 18, + "white", + FONT_R, + (80, 540), + ), + ( + "Otsu: automatycznie testuje T=0..255, minimalizuje σ² wewnątrzklasową", + 16, + "#A5D6A7", + FONT_R, + (80, 580), + ), + ( + "Piksel ≤ T → klasa 0 (tło) | Piksel > T → klasa 1 (obiekt)", + 16, + "#78909C", + FONT_R, + (80, 620), + ), + ] + for text, fs, color, font, pos in labels: + tc = ( + _tc(text=text, font_size=fs, color=color, font=font) + .with_duration(STEP_DUR) + .with_position(pos) + ) + text_clips.append(tc) + + slides.append( + CompositeVideoClip(text_clips, size=(W, H)).with_effects( + [FadeIn(0.3), FadeOut(0.3)] + ) + ) + return slides + + +# ── Region Growing ──────────────────────────────────────────────── +def _region_growing_demo() -> list[CompositeVideoClip]: + """Animate region growing BFS from a seed pixel.""" + slides = [] + + grid_size = 10 + cell_size = 40 + rng = np.random.default_rng(42) + # Create a simple grid: dark region (30-80) and bright region (160-220) + grid = np.zeros((grid_size, grid_size), dtype=np.uint8) + grid[:] = 60 # dark background + grid[2:7, 3:8] = 180 # bright rectangle + + # Add some noise + noise = rng.integers(-15, 15, (grid_size, grid_size)) + grid = np.clip(grid.astype(int) + noise, 0, 255).astype(np.uint8) + + # BFS steps from seed (4, 5) + seed = (4, 5) + threshold_val = 50 + visited_order: list[tuple[int, int]] = [] + queue = [seed] + visited_set = {seed} + while queue: + r, c = queue.pop(0) + visited_order.append((r, c)) + for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1)]: + nr, nc = r + dr, c + dc + if ( + 0 <= nr < grid_size + and 0 <= nc < grid_size + and (nr, nc) not in visited_set + ) and abs(int(grid[nr, nc]) - int(grid[seed])) < threshold_val: + visited_set.add((nr, nc)) + queue.append((nr, nc)) + + def make_region_frame(t: float) -> np.ndarray: + frame = np.zeros((H, W, 3), dtype=np.uint8) + frame[:] = BG_COLOR + ox, oy = 100, 180 + + # How many cells to show as visited + progress = min(t / (STEP_DUR * 0.8), 1.0) + n_visited = int(progress * len(visited_order)) + + for r in range(grid_size): + for c in range(grid_size): + x = ox + c * cell_size + y = oy + r * cell_size + val = grid[r, c] + color = (val, val, val) + + # Highlight visited + if (r, c) in visited_order[:n_visited]: + color = (80, 200, 120) # green for region + elif (r, c) == seed: + color = (255, 200, 50) # yellow seed + + frame[y : y + cell_size - 2, x : x + cell_size - 2] = color + + # Mark the seed with a bright border + sx = ox + seed[1] * cell_size + sy = ox + seed[0] * cell_size + 80 + frame[sy : sy + cell_size, sx : sx + 2] = (255, 200, 50) + frame[sy : sy + cell_size, sx + cell_size - 2 : sx + cell_size] = (255, 200, 50) + frame[sy : sy + 2, sx : sx + cell_size] = (255, 200, 50) + frame[sy + cell_size - 2 : sy + cell_size, sx : sx + cell_size] = (255, 200, 50) + + return frame + + region_clip = VideoClip(make_region_frame, duration=STEP_DUR).with_fps(FPS) + text_clips: list[VideoClip] = [region_clip] + labels = [ + ("Region Growing — rozrastanie regionu", 28, "#FFE082", FONT_B, (100, 30)), + ("Seed (ziarno) → BFS do podobnych sąsiadów", 20, "#B0BEC5", FONT_R, (100, 80)), + ( + "Żółty = seed | Zielony = region | Szary = nieodwiedzone", + 16, + "#78909C", + FONT_R, + (100, 120), + ), + ( + "Sąsiad PODOBNY (|jasność - jasność_regionu| < próg) → dodaj do regionu", + 16, + "#A5D6A7", + FONT_R, + (100, 600), + ), + ( + "Algorytm zatrzymuje się gdy brak podobnych sąsiadów", + 16, + "#90CAF9", + FONT_R, + (100, 640), + ), + ( + "Mnemonik: PLAMA atramentu — rozlewa się na podobne piksele", + 18, + "#EF9A9A", + FONT_R, + (100, 670), + ), + ] + for text, fs, color, font, pos in labels: + tc = ( + _tc(text=text, font_size=fs, color=color, font=font) + .with_duration(STEP_DUR) + .with_position(pos) + ) + text_clips.append(tc) + + slides.append( + CompositeVideoClip(text_clips, size=(W, H)).with_effects( + [FadeIn(0.3), FadeOut(0.3)] + ) + ) + return slides + + +# ── Watershed ───────────────────────────────────────────────────── +def _watershed_demo() -> list[CompositeVideoClip]: + """Animate watershed flooding concept.""" + slides = [] + + def make_watershed_frame(t: float) -> np.ndarray: + frame = np.zeros((H, W, 3), dtype=np.uint8) + frame[:] = BG_COLOR + + # Draw terrain profile (1D cross-section) + ox, oy = 100, 450 + terrain_w = 900 + terrain_points = 100 + + xs = np.linspace(0, 1, terrain_points) + # Two valleys with a ridge + terrain = ( + 120 * np.exp(-((xs - 0.25) ** 2) / 0.005) + + 80 * np.exp(-((xs - 0.75) ** 2) / 0.008) + + 30 + ) + terrain = 250 - terrain # invert for visual (valleys at bottom) + + # Water level rises over time + water_level = int(160 + 80 * min(t / (STEP_DUR * 0.7), 1.0)) + + for i in range(terrain_points - 1): + x1 = ox + int(xs[i] * terrain_w) + x2 = ox + int(xs[i + 1] * terrain_w) + y1 = oy - int(terrain[i]) + y2 = oy - int(terrain[i + 1]) + + # Fill terrain + for x in range(x1, min(x2 + 1, W)): + top = min(y1, y2) - 5 + frame[top:oy, x : x + 1] = (100, 80, 60) + + # Fill water + water_y = oy - water_level + for x in range(x1, min(x2 + 1, W)): + t_y = oy - int(terrain[i]) + if water_y < t_y: + # Water fills below terrain surface + fill_top = max(water_y, 0) + fill_bot = min(t_y, oy) + if fill_top < fill_bot: + frame[fill_top:fill_bot, x : x + 1] = (70, 130, 220) + + # Dam marker at ridge + ridge_x = ox + int(0.5 * terrain_w) + dam_visible_threshold = 160 + if water_level > dam_visible_threshold: + frame[oy - water_level : oy - 140, ridge_x - 2 : ridge_x + 2] = ( + 255, + 80, + 80, + ) + + return frame + + ws_clip = VideoClip(make_watershed_frame, duration=STEP_DUR).with_fps(FPS) + text_clips: list[VideoClip] = [ws_clip] + labels = [ + ("Watershed — metoda zlewiska", 28, "#FFE082", FONT_B, (100, 20)), + ( + "Obraz = mapa topograficzna (jasność = wysokość)", + 20, + "#B0BEC5", + FONT_R, + (100, 65), + ), + ( + "Brązowy = teren (ciemne=doliny, jasne=szczyty)", + 16, + "#8D6E63", + FONT_R, + (100, 100), + ), + ("Niebieski = woda zalewająca od minimów", 16, "#64B5F6", FONT_R, (100, 130)), + ( + "Czerwony = TAMA (granica segmentu) — gdy woda z 2 dolin się spotka", + 16, + "#EF9A9A", + FONT_R, + (100, 160), + ), + ( + "Problem: over-segmentation " + "(za dużo regionów). " + "Rozwiązanie: marker-controlled.", + 16, + "#A5D6A7", + FONT_R, + (100, 560), + ), + ( + "Mnemonik: ZALEWANIE terenu — granie gór = granice segmentów", + 18, + "#FFE082", + FONT_R, + (100, 600), + ), + ] + for text, fs, color, font, pos in labels: + tc = ( + _tc(text=text, font_size=fs, color=color, font=font) + .with_duration(STEP_DUR) + .with_position(pos) + ) + text_clips.append(tc) + + slides.append( + CompositeVideoClip(text_clips, size=(W, H)).with_effects( + [FadeIn(0.3), FadeOut(0.3)] + ) + ) + return slides diff --git a/python_pkg/praca_magisterska_video/_q23_deeplab.py b/python_pkg/praca_magisterska_video/_q23_deeplab.py new file mode 100644 index 0000000..e1a5ff1 --- /dev/null +++ b/python_pkg/praca_magisterska_video/_q23_deeplab.py @@ -0,0 +1,248 @@ +"""DeepLab architecture animations for Q23 segmentation video.""" + +from __future__ import annotations + +from moviepy import ( + CompositeVideoClip, + VideoClip, +) +import numpy as np + +from python_pkg.praca_magisterska_video._q23_helpers import ( + BG_COLOR, + FONT_B, + FONT_R, + FPS, + STEP_DUR, + H, + W, + _compose_slide, +) + + +# ── DeepLab Architecture ───────────────────────────────────────── +def _make_dilated_frame(t: float) -> np.ndarray: + """Render a dilated convolution comparison frame.""" + frame = np.zeros((H, W, 3), dtype=np.uint8) + frame[:] = BG_COLOR + progress = min(t / (STEP_DUR * 0.7), 1.0) + + cell = 36 + grids = [ + ( + "rate=1", + 60, + [ + (0, 0), + (0, 1), + (0, 2), + (1, 0), + (1, 1), + (1, 2), + (2, 0), + (2, 1), + (2, 2), + ], + ), + ( + "rate=2", + 420, + [ + (0, 0), + (0, 2), + (0, 4), + (2, 0), + (2, 2), + (2, 4), + (4, 0), + (4, 2), + (4, 4), + ], + ), + ( + "rate=3", + 820, + [ + (0, 0), + (0, 3), + (0, 6), + (3, 0), + (3, 3), + (3, 6), + (6, 0), + (6, 3), + (6, 6), + ], + ), + ] + + for gi, (_label, gx, positions) in enumerate(grids): + if progress < gi * 0.3: + break + gy = 180 + grid_size = 7 + for r in range(grid_size): + for c in range(grid_size): + x = gx + c * cell + y = gy + r * cell + frame[y : y + cell - 2, x : x + cell - 2] = (35, 40, 55) + for r, c in positions: + x = gx + c * cell + y = gy + r * cell + frame[y : y + cell - 2, x : x + cell - 2] = (70, 130, 200) + frame[y : y + 2, x : x + cell - 2] = (120, 180, 255) + frame[y + cell - 4 : y + cell - 2, x : x + cell - 2] = (120, 180, 255) + + return frame + + +def _make_aspp_frame(t: float) -> np.ndarray: + """Render a single ASPP module animation frame.""" + frame = np.zeros((H, W, 3), dtype=np.uint8) + frame[:] = BG_COLOR + progress = min(t / (STEP_DUR * 0.7), 1.0) + + frame[250:330, 50:130] = (70, 130, 200) + frame[250:252, 50:130] = (120, 180, 255) + frame[328:330, 50:130] = (120, 180, 255) + + branches = [ + ("1x1 conv", 250, (200, 170), (100, 40), (80, 200, 120)), + ("rate=6", 310, (200, 250), (100, 40), (200, 160, 80)), + ("rate=12", 370, (200, 330), (100, 40), (200, 120, 60)), + ("rate=18", 430, (200, 410), (100, 40), (180, 100, 80)), + ("GAP", 490, (200, 490), (100, 40), (160, 80, 160)), + ] + n_branches = min(int(progress * 5) + 1, 5) + for i, (_lbl, _h, (bx, by), (bw, bh), color) in enumerate(branches): + if i < n_branches: + frame[by : by + bh, bx : bx + bw] = color + frame[by : by + 2, bx : bx + bw] = tuple(min(c + 50, 255) for c in color) + ay = by + bh // 2 + frame[ay - 1 : ay + 2, 133:197] = (150, 150, 170) + + concat_phase = 0.6 + if progress > concat_phase: + frame[250:530, 380:420] = (50, 60, 80) + frame[250:252, 380:420] = (200, 200, 100) + frame[528:530, 380:420] = (200, 200, 100) + for i, (_lbl, _h, (bx, by), (bw, bh), _c) in enumerate(branches): + if i < n_branches: + ay = by + bh // 2 + frame[ay - 1 : ay + 2, bx + bw + 3 : 378] = (150, 150, 170) + + final_conv_phase = 0.8 + if progress > final_conv_phase: + frame[350:420, 450:550] = (100, 200, 100) + frame[350:352, 450:550] = (150, 230, 150) + frame[418:420, 450:550] = (150, 230, 150) + frame[388:391, 423:448] = (150, 150, 170) + + return frame + + +def _deeplab_demo() -> list[CompositeVideoClip]: + """Animate DeepLab: dilated convolution + ASPP step by step.""" + dur = STEP_DUR + 1 + + # Slide 1: Regular vs Dilated convolution + dil_clip = VideoClip(_make_dilated_frame, duration=dur).with_fps(FPS) + labels = [ + ("DeepLab: Atrous (Dilated) Convolution", 26, "#FFE082", FONT_B, (80, 20)), + ( + "KROK 1: Zrozum dilated convolution — filtr z DZIURAMI", + 18, + "#A5D6A7", + FONT_R, + (80, 60), + ), + ("rate=1 (zwykła)", 14, "#64B5F6", FONT_B, (60, 160)), + ("RF = 3x3", 14, "#64B5F6", FONT_R, (60, 440)), + ("9 wag, kontekst 3px", 12, "#78909C", FONT_R, (60, 470)), + ("rate=2 (dilated)", 14, "#FFE082", FONT_B, (420, 160)), + ("RF = 5x5", 14, "#FFE082", FONT_R, (420, 440)), + ("9 wag, kontekst 5px!", 12, "#78909C", FONT_R, (420, 470)), + ("rate=3 (dilated)", 14, "#A5D6A7", FONT_B, (820, 160)), + ("RF = 7x7", 14, "#A5D6A7", FONT_R, (820, 440)), + ("9 wag, kontekst 7px!", 12, "#78909C", FONT_R, (820, 470)), + ( + "Niebieski = pozycja wag filtra 3x3 | Szary = pominięte (dziury)", + 15, + "#B0BEC5", + FONT_R, + (80, 510), + ), + ( + "TE SAME 9 wag → WIĘKSZE pole widzenia " + "→ lepszy kontekst BEZ dodatkowych parametrów!", + 16, + "white", + FONT_R, + (80, 550), + ), + ( + "Mnemonik: DZIURY w filtrze — à trous = z dziurami (fr.)", + 16, + "#FFE082", + FONT_R, + (80, 600), + ), + ] + slides = [_compose_slide(dil_clip, labels, dur)] + + # Slide 2: ASPP module step by step + aspp_clip = VideoClip(_make_aspp_frame, duration=dur).with_fps(FPS) + labels2 = [ + ( + "DeepLab: ASPP (Atrous Spatial Pyramid Pooling)", + 24, + "#FFE082", + FONT_B, + (80, 20), + ), + ( + "KROK 2: Multi-scale — analizuj obraz na WIELU skalach naraz", + 17, + "#A5D6A7", + FONT_R, + (80, 60), + ), + ("Wejście", 13, "#64B5F6", FONT_B, (55, 235)), + ("Conv 1x1", 12, "white", FONT_R, (210, 178)), + ("Dilated r=6", 12, "white", FONT_R, (205, 258)), + ("Dilated r=12", 12, "white", FONT_R, (203, 338)), + ("Dilated r=18", 12, "white", FONT_R, (203, 418)), + ("GAP (global)", 12, "white", FONT_R, (205, 498)), + ("Concat", 13, "#FFE082", FONT_B, (381, 537)), + ("Conv", 13, "#A5D6A7", FONT_B, (470, 425)), + ( + "5 gałęzi RÓWNOLEGŁYCH → różne skale kontekstu:", + 16, + "#B0BEC5", + FONT_R, + (550, 170), + ), + (" 1x1: kontekst punktowy (piksel)", 14, "#A5D6A7", FONT_R, (560, 210)), + (" r=6: kontekst lokalny (~13px)", 14, "#FFE082", FONT_R, (560, 245)), + (" r=12: kontekst średni (~25px)", 14, "#FFE082", FONT_R, (560, 280)), + (" r=18: kontekst szeroki (~37px)", 14, "#FFE082", FONT_R, (560, 315)), + (" GAP: kontekst GLOBALNY (cały obraz)", 14, "#CE93D8", FONT_R, (560, 350)), + ("Concat → 1x1 conv → mapa segmentacji", 16, "#A5D6A7", FONT_R, (550, 400)), + ( + "Efekt: sieć widzi OD piksela DO całego obrazu naraz!", + 17, + "white", + FONT_R, + (80, 600), + ), + ( + "Mnemonik: ASPP = Piramida z DZIURAMI, patrzy na 5 skal jednocześnie", + 15, + "#FFE082", + FONT_R, + (80, 645), + ), + ] + slides.append(_compose_slide(aspp_clip, labels2, dur)) + + return slides diff --git a/python_pkg/praca_magisterska_video/_q23_helpers.py b/python_pkg/praca_magisterska_video/_q23_helpers.py new file mode 100644 index 0000000..32a5757 --- /dev/null +++ b/python_pkg/praca_magisterska_video/_q23_helpers.py @@ -0,0 +1,116 @@ +"""Shared constants and helper functions for Q23 segmentation video.""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path + +import numpy as np + +os.environ["FFMPEG_BINARY"] = "/usr/bin/ffmpeg" + +from moviepy import ( + ColorClip, + CompositeVideoClip, + TextClip, + VideoClip, +) +from moviepy.video.fx import FadeIn, FadeOut + +# ── Constants ───────────────────────────────────────────────────── +W, H = 1280, 720 +FPS = 24 +STEP_DUR = 7.0 +HEADER_DUR = 4.0 +FONT_B = "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf" +FONT_R = "/usr/share/fonts/TTF/DejaVuSans.ttf" +OUTPUT_DIR = Path(__file__).resolve().parent / "videos" +OUTPUT_DIR.mkdir(parents=True, exist_ok=True) +OUTPUT = str(OUTPUT_DIR / "q23_segmentation.mp4") + +logging.basicConfig(level=logging.INFO) +_logger = logging.getLogger(__name__) + +BG_COLOR = (15, 20, 35) +rng = np.random.default_rng(42) + + +def _tc(**kwargs: object) -> TextClip: + """TextClip wrapper that adds enough bottom margin to prevent clipping.""" + fs = kwargs.get("font_size", 24) + m = int(fs) // 3 + 2 + kwargs["margin"] = (0, m) + return TextClip(**kwargs) + + +def _make_header( + title: str, subtitle: str, duration: float = HEADER_DUR +) -> CompositeVideoClip: + bg = ColorClip(size=(W, H), color=BG_COLOR).with_duration(duration) + t = ( + _tc( + text=title, + font_size=48, + color="white", + font=FONT_B, + ) + .with_duration(duration) + .with_position(("center", 260)) + ) + s = ( + _tc( + text=subtitle, + font_size=24, + color="#90CAF9", + font=FONT_R, + ) + .with_duration(duration) + .with_position(("center", 340)) + ) + return CompositeVideoClip([bg, t, s], size=(W, H)).with_effects( + [FadeIn(0.5), FadeOut(0.5)] + ) + + +def _text_slide( + lines: list[tuple[str, int, str, str, tuple[str | int, str | int]]], + duration: float = STEP_DUR, +) -> CompositeVideoClip: + """Create a slide with multiple text elements.""" + bg = ColorClip(size=(W, H), color=BG_COLOR).with_duration(duration) + clips: list[VideoClip] = [bg] + for text, font_size, color, font, pos in lines: + tc = ( + _tc( + text=text, + font_size=font_size, + color=color, + font=font, + ) + .with_duration(duration) + .with_position(pos) + ) + clips.append(tc) + return CompositeVideoClip(clips, size=(W, H)).with_effects( + [FadeIn(0.3), FadeOut(0.3)] + ) + + +def _compose_slide( + base_clip: VideoClip, + labels: list[tuple[str, int, str, str, tuple[int, int]]], + duration: float, +) -> CompositeVideoClip: + """Overlay text labels on an animated base clip.""" + text_clips: list[VideoClip] = [base_clip] + for text, fs, color, font, pos in labels: + tc = ( + _tc(text=text, font_size=fs, color=color, font=font) + .with_duration(duration) + .with_position(pos) + ) + text_clips.append(tc) + return CompositeVideoClip(text_clips, size=(W, H)).with_effects( + [FadeIn(0.3), FadeOut(0.3)] + ) diff --git a/python_pkg/praca_magisterska_video/_q23_transformer.py b/python_pkg/praca_magisterska_video/_q23_transformer.py new file mode 100644 index 0000000..55f9fcf --- /dev/null +++ b/python_pkg/praca_magisterska_video/_q23_transformer.py @@ -0,0 +1,430 @@ +"""Transformer segmentation and methods comparison for Q23 video.""" + +from __future__ import annotations + +from moviepy import ( + ColorClip, + CompositeVideoClip, + VideoClip, +) +from moviepy.video.fx import FadeIn, FadeOut +import numpy as np + +from python_pkg.praca_magisterska_video._q23_helpers import ( + BG_COLOR, + FONT_B, + FONT_R, + FPS, + STEP_DUR, + H, + W, + _compose_slide, + _tc, + _text_slide, +) + + +# ── Transformer Segmentation ──────────────────────────────────── +def _draw_base_grid( + frame: np.ndarray, + gx: int, + gy: int, + grid_n: int, + cell: int, +) -> None: + """Draw an empty grid of cells.""" + for r in range(grid_n): + for c in range(grid_n): + x = gx + c * cell + y = gy + r * cell + frame[y : y + cell - 2, x : x + cell - 2] = (35, 40, 55) + + +def _draw_cnn_kernel( + frame: np.ndarray, + lx: int, + ly: int, + cell: int, + progress: float, +) -> None: + """Highlight a 3x3 CNN kernel on the grid.""" + cnn_phase = 0.2 + if progress <= cnn_phase: + return + cx, cy = 2, 2 + for dr in range(-1, 2): + for dc in range(-1, 2): + r, c = cy + dr, cx + dc + x = lx + c * cell + y = ly + r * cell + frame[y : y + cell - 2, x : x + cell - 2] = (70, 130, 200) + x = lx + cx * cell + y = ly + cy * cell + frame[y : y + cell - 2, x : x + cell - 2] = (120, 180, 255) + + +def _draw_conn_line( + frame: np.ndarray, + x0: int, + y0: int, + x1: int, + y1: int, +) -> None: + """Draw a dashed connection line between two points.""" + steps = max(abs(x1 - x0), abs(y1 - y0)) + if steps <= 0: + return + for s in range(0, steps, 3): + px = x0 + int((x1 - x0) * s / steps) + py = y0 + int((y1 - y0) * s / steps) + if 0 <= px < W - 1 and 0 <= py < H - 1: + frame[py : py + 1, px : px + 1] = (200, 180, 50) + + +def _draw_attention_connections( + frame: np.ndarray, + origin: tuple[int, int], + grid_n: int, + cell: int, + progress: float, +) -> None: + """Draw transformer self-attention connections on the grid.""" + rx, ry = origin + transformer_phase = 0.4 + if progress <= transformer_phase: + return + cx_t, cy_t = 2, 2 + x0 = rx + cx_t * cell + cell // 2 + y0 = ry + cy_t * cell + cell // 2 + n_connections = int(progress * 36) + conn_idx = 0 + for r in range(grid_n): + for c in range(grid_n): + conn_idx += 1 + if conn_idx > n_connections: + break + x = rx + c * cell + y = ry + r * cell + dist = abs(r - cy_t) + abs(c - cx_t) + strength = max(30, 200 - dist * 30) + frame[y : y + cell - 2, x : x + cell - 2] = ( + strength // 3, + strength // 2, + strength, + ) + _draw_conn_line(frame, x0, y0, x + cell // 2, y + cell // 2) + else: + continue + break + x = rx + cx_t * cell + y = ry + cy_t * cell + frame[y : y + cell - 2, x : x + cell - 2] = (255, 200, 50) + + +def _make_attention_frame(t: float) -> np.ndarray: + """Render a CNN-vs-Transformer attention comparison frame.""" + frame = np.zeros((H, W, 3), dtype=np.uint8) + frame[:] = BG_COLOR + progress = min(t / (STEP_DUR * 0.7), 1.0) + + cell = 40 + grid_n = 6 + + lx, ly = 60, 200 + _draw_base_grid(frame, lx, ly, grid_n, cell) + _draw_cnn_kernel(frame, lx, ly, cell, progress) + + rx, ry = 680, 200 + _draw_base_grid(frame, rx, ry, grid_n, cell) + _draw_attention_connections(frame, (rx, ry), grid_n, cell, progress) + + return frame + + +def _transformer_seg_demo() -> list[CompositeVideoClip]: + """Animate transformer-based segmentation: self-attention concept.""" + dur = STEP_DUR + 1 + + # Slide 1: CNN local vs Transformer global + att_clip = VideoClip(_make_attention_frame, duration=dur).with_fps(FPS) + labels = [ + ("Transformer: Self-Attention w segmentacji", 26, "#FFE082", FONT_B, (80, 20)), + ("CNN = LOKALNY kontekst", 18, "#64B5F6", FONT_B, (60, 160)), + ("Transformer = GLOBALNY kontekst", 18, "#FFE082", FONT_B, (680, 160)), + ("Filtr 3x3 widzi", 14, "#64B5F6", FONT_R, (60, 460)), + ("TYLKO 9 sąsiadów", 14, "#64B5F6", FONT_R, (60, 485)), + ("Self-attention: każdy", 14, "#FFE082", FONT_R, (680, 460)), + ("piksel widzi WSZYSTKIE!", 14, "#FFE082", FONT_R, (680, 485)), + ("vs", 28, "#B0BEC5", FONT_B, (450, 300)), + ] + slides = [_compose_slide(att_clip, labels, dur)] + + # Slide 2: Self-attention Q/K/V step by step + qkv_lines = [ + ("Self-Attention: Q / K / V krok po kroku", 26, "#FFE082", FONT_B, (80, 30)), + ("Każdy piksel (token) tworzy 3 wektory:", 18, "#B0BEC5", FONT_R, (100, 100)), + ( + " Q (Query) = 'czego szukam?' - pytanie piksela", + 17, + "#64B5F6", + FONT_R, + (120, 145), + ), + ( + " K (Key) = 'co oferuj\u0119?' - odpowied\u017a piksela", + 17, + "#A5D6A7", + FONT_R, + (120, 185), + ), + ( + " V (Value) = 'moja warto\u015b\u0107' - informacja do przekazania", + 17, + "#FFE082", + FONT_R, + (120, 225), + ), + ("Algorytm attention:", 18, "#B0BEC5", FONT_R, (100, 285)), + ( + " 1. Mnożenie Q x K\u1d40 → macierz NxN (kto ważny dla kogo)", + 16, + "white", + FONT_R, + (120, 320), + ), + ( + " 2. Skalowanie: / \u221ad (stabilno\u015b\u0107 gradient\u00f3w)", + 16, + "white", + FONT_R, + (120, 355), + ), + ( + " 3. Softmax \u2192 wagi attention (sumuj\u0105 si\u0119 do 1)", + 16, + "white", + FONT_R, + (120, 390), + ), + ( + " 4. Mno\u017cenie wag x V \u2192 wa\u017cona suma warto\u015bci", + 16, + "white", + FONT_R, + (120, 425), + ), + ( + "Attention(Q,K,V) = softmax(Q \u00b7 K\u1d40 / \u221ad) \u00b7 V", + 20, + "#FFE082", + FONT_B, + (100, 480), + ), + ( + "Z\u0142o\u017cono\u015b\u0107: O(n\u00b2) pami\u0119ci \u2014 n = liczba pikseli/token\u00f3w", + 16, + "#EF9A9A", + FONT_R, + (100, 535), + ), + ( + "Dlatego SegFormer u\u017cywa efficient attention (liniowa z\u0142o\u017cono\u015b\u0107)", + 15, + "#78909C", + FONT_R, + (100, 570), + ), + ( + "SegFormer (2021): lightweight + hierarchiczny encoder", + 16, + "#A5D6A7", + FONT_R, + (100, 610), + ), + ( + "Mask2Former (2022): masked attention + " + "unified (semantic+instance+panoptic)", + 16, + "#CE93D8", + FONT_R, + (100, 645), + ), + ] + slides.append(_text_slide(qkv_lines, duration=STEP_DUR + 1)) + + # Slide 3: Encoder-Decoder in DL summary + summary_lines = [ + ( + "Podsumowanie: Encoder-Decoder w segmentacji DL", + 24, + "#FFE082", + FONT_B, + (80, 30), + ), + ( + "Wsp\u00f3lna idea WSZYSTKICH sieci segmentacji:", + 18, + "#B0BEC5", + FONT_R, + (80, 90), + ), + ( + "Encoder: obraz \u2192 cechy (zmniejsza rozdzielczo\u015b\u0107, wyci\u0105ga CO)", + 16, + "#64B5F6", + FONT_R, + (100, 140), + ), + ( + "Decoder: cechy \u2192 mapa (zwi\u0119ksza rozdzielczo\u015b\u0107, odtwarza GDZIE)", + 16, + "#A5D6A7", + FONT_R, + (100, 175), + ), + ( + "Skip: przenosi detale z encodera do decodera", + 16, + "#FFE082", + FONT_R, + (100, 210), + ), + ("", 10, "white", FONT_R, (100, 240)), + ( + "FCN (2015): Conv1x1 + skip \u2192 pierwsza end-to-end", + 16, + "#64B5F6", + FONT_R, + (100, 275), + ), + ( + "U-Net (2015): U-shape + skip concat \u2192 segmentacja medyczna", + 16, + "#A5D6A7", + FONT_R, + (100, 310), + ), + ( + "DeepLab (2018): dilated conv + ASPP \u2192 multi-scale kontekst", + 16, + "#FFE082", + FONT_R, + (100, 345), + ), + ( + "SegFormer: transformer encoder (globalny kontekst)", + 16, + "#CE93D8", + FONT_R, + (100, 380), + ), + ( + "Mask2Former: masked attention (unified, SOTA)", + 16, + "#CE93D8", + FONT_R, + (100, 415), + ), + ("", 10, "white", FONT_R, (100, 440)), + ( + "Ewolucja: wi\u0119cej kontekstu + lepsze skip connections:", + 17, + "white", + FONT_R, + (80, 465), + ), + ( + " CNN lokal. \u2192 dilated (szersze RF) \u2192 transformer (global) \u2192 masked att.", + 16, + "#B0BEC5", + FONT_R, + (80, 505), + ), + ( + " addition skip \u2192 concat skip \u2192 cross-attention skip", + 16, + "#B0BEC5", + FONT_R, + (80, 540), + ), + ( + "Metryki: mIoU (standard), Dice (medycyna), Focal Loss (imbalance)", + 16, + "#90CAF9", + FONT_R, + (80, 590), + ), + ( + "Loss: Cross-Entropy per piksel + opcjonalnie Dice/Focal", + 15, + "#78909C", + FONT_R, + (80, 625), + ), + ] + slides.append(_text_slide(summary_lines, duration=STEP_DUR + 1)) + + return slides + + +# ── Methods comparison ──────────────────────────────────────────── +def _methods_comparison() -> CompositeVideoClip: + """Create a comparison table of all segmentation methods.""" + bg = ColorClip(size=(W, H), color=BG_COLOR).with_duration(10.0) + title = ( + _tc( + text="Por\u00f3wnanie metod segmentacji", + font_size=36, + color="white", + font=FONT_B, + ) + .with_duration(10.0) + .with_position(("center", 20)) + ) + + rows = [ + ("Metoda", "Typ", "Idea", "Mnemonik"), + ( + "Thresholding", + "Klasyczna", + "piksel > T \u2192 klasa 1", + "PR\u00d3G na bramce", + ), + ("Otsu", "Klasyczna", "auto-pr\u00f3g, min \u03c3\u00b2", "AUTO-bramkarz"), + ("Region Growing", "Klasyczna", "BFS od seeda", "PLAMA atramentu"), + ("Watershed", "Klasyczna", "zalewanie minim\u00f3w", "ZALEWANIE terenu"), + ( + "Mean Shift", + "Klasyczna", + "j\u0105dro \u2192 max g\u0119sto\u015bci", + "KULKI do do\u0142k\u00f3w", + ), + ("U-Net", "Deep Learning", "encoder-decoder + skip", "Litera U + mosty"), + ("DeepLab", "Deep Learning", "dilated conv + ASPP", "DZIURY w filtrze"), + ] + + clips: list[VideoClip] = [bg, title] + mnemonic_col = 3 + for i, row in enumerate(rows): + y_pos = 75 + i * 72 + col_x = [40, 210, 340, 660] + for j, cell in enumerate(row): + fs = 16 if i > 0 else 18 + color = ( + "#64B5F6" if i == 0 else ("#E0E0E0" if j < mnemonic_col else "#FFE082") + ) + tc = ( + _tc( + text=cell, + font_size=fs, + color=color, + font=FONT_B if i == 0 else FONT_R, + ) + .with_duration(10.0) + .with_position((col_x[j], y_pos)) + ) + clips.append(tc) + + return CompositeVideoClip(clips, size=(W, H)).with_effects( + [FadeIn(0.5), FadeOut(0.5)] + ) diff --git a/python_pkg/praca_magisterska_video/_q23_unet_fcn.py b/python_pkg/praca_magisterska_video/_q23_unet_fcn.py new file mode 100644 index 0000000..779d88e --- /dev/null +++ b/python_pkg/praca_magisterska_video/_q23_unet_fcn.py @@ -0,0 +1,399 @@ +"""U-Net and FCN architecture animations for Q23 segmentation video.""" + +from __future__ import annotations + +from moviepy import ( + CompositeVideoClip, + VideoClip, +) +import numpy as np + +from python_pkg.praca_magisterska_video._q23_helpers import ( + BG_COLOR, + FONT_B, + FONT_R, + FPS, + STEP_DUR, + H, + W, + _compose_slide, + _text_slide, +) + + +# ── U-Net Architecture ─────────────────────────────────────────── +def _draw_unet_skips( + frame: np.ndarray, + enc_positions: list[tuple[int, int, int, int]], + n_blocks: int, + dec_x: int, + skip_threshold: int, +) -> None: + """Draw horizontal dashed skip-connection lines.""" + if n_blocks <= skip_threshold: + return + for i in range(min(n_blocks - 5, 4)): + ey = enc_positions[i][1] + enc_positions[i][3] // 2 + ex_end = enc_positions[i][0] + enc_positions[i][2] + for dash_x in range(ex_end + 10, dec_x - 10, 15): + frame[ey : ey + 2, dash_x : dash_x + 8] = (255, 200, 50) + + +def _make_unet_frame(t: float) -> np.ndarray: + """Render a single U-Net animation frame.""" + frame = np.zeros((H, W, 3), dtype=np.uint8) + frame[:] = BG_COLOR + + enc_sizes = [(80, 120), (60, 100), (45, 80), (30, 60)] + dec_sizes = list(reversed(enc_sizes)) + enc_x = 150 + dec_x = 850 + + progress = min(t / (STEP_DUR * 0.6), 1.0) + n_blocks = int(progress * 8) + 1 + + enc_positions: list[tuple[int, int, int, int]] = [] + y_offset = 120 + for i, (bw, bh) in enumerate(enc_sizes): + x = enc_x + y = y_offset + i * 130 + enc_positions.append((x, y, bw, bh)) + if i < n_blocks: + frame[y : y + bh, x : x + bw] = (70, 130, 200) + frame[y : y + 2, x : x + bw] = (100, 180, 255) + frame[y + bh - 2 : y + bh, x : x + bw] = (100, 180, 255) + frame[y : y + bh, x : x + 2] = (100, 180, 255) + frame[y : y + bh, x + bw - 2 : x + bw] = (100, 180, 255) + if i < len(enc_sizes) - 1: + ax = x + bw // 2 + ay = y + bh + 10 + frame[ay : ay + 20, ax - 1 : ax + 2] = (150, 150, 170) + + bx, by = 500, y_offset + 3 * 130 + 30 + encoder_count = 4 + if n_blocks > encoder_count: + frame[by : by + 50, bx : bx + 25] = (200, 100, 80) + frame[by : by + 2, bx : bx + 25] = (255, 140, 100) + frame[by + 48 : by + 50, bx : bx + 25] = (255, 140, 100) + + for i, (bw, bh) in enumerate(dec_sizes): + x = dec_x + y = y_offset + (3 - i) * 130 + if n_blocks > 4 + i + 1: + frame[y : y + bh, x : x + bw] = (80, 200, 120) + frame[y : y + 2, x : x + bw] = (120, 230, 150) + frame[y + bh - 2 : y + bh, x : x + bw] = (120, 230, 150) + frame[y : y + bh, x : x + 2] = (120, 230, 150) + frame[y : y + bh, x + bw - 2 : x + bw] = (120, 230, 150) + if i < len(dec_sizes) - 1: + ax = x + bw // 2 + ay = y - 30 + frame[ay : ay + 20, ax - 1 : ax + 2] = (150, 150, 170) + + skip_threshold = 5 + _draw_unet_skips(frame, enc_positions, n_blocks, dec_x, skip_threshold) + + return frame + + +def _unet_demo() -> list[CompositeVideoClip]: + """Animate U-Net encoder-decoder architecture.""" + dur = STEP_DUR + 1 + unet_clip = VideoClip(_make_unet_frame, duration=dur).with_fps(FPS) + labels = [ + ("U-Net: Encoder-Decoder + Skip Connections", 28, "#FFE082", FONT_B, (80, 20)), + ( + "Niebieski = Encoder (↓ zmniejsza rozdzielczość, wyciąga cechy)", + 16, + "#64B5F6", + FONT_R, + (80, 65), + ), + ( + "Zielony = Decoder (↑ zwiększa rozdzielczość, odtwarza mapę)", + 16, + "#A5D6A7", + FONT_R, + (80, 90), + ), + ( + "Żółte przerywane = Skip connections (przenoszą detale z encodera)", + 16, + "#FFE082", + FONT_R, + (80, 115), + ), + ( + "Czerwony = Bottleneck (najgłębsza warstwa, max abstrakcja)", + 16, + "#EF9A9A", + FONT_R, + (450, 570), + ), + ( + "Kształt U: encoder ↓ decoder ↑, mosty pośrodku", + 18, + "white", + FONT_R, + (80, 640), + ), + ( + "Concatenation: skip łączy kanały (więcej informacji niż dodawanie)", + 16, + "#78909C", + FONT_R, + (80, 670), + ), + ] + return [_compose_slide(unet_clip, labels, dur)] + + +# ── FCN Architecture ───────────────────────────────────────────── +def _draw_pipeline_blocks( + frame: np.ndarray, + blocks: list[tuple[tuple[int, int], tuple[int, int], tuple[int, int, int]]], + n_visible: int, + arrow_limit: int, +) -> None: + """Draw coloured blocks with connecting arrows.""" + for i, ((bx, by), (bw, bh), color) in enumerate(blocks): + if i < n_visible: + frame[by : by + bh, bx : bx + bw] = color + frame[by : by + 2, bx : bx + bw] = tuple(min(c + 50, 255) for c in color) + frame[by + bh - 2 : by + bh, bx : bx + bw] = tuple( + min(c + 50, 255) for c in color + ) + if i < arrow_limit: + ax = bx + bw + 3 + ay = by + bh // 2 + frame[ay - 1 : ay + 2, ax : ax + 12] = (150, 150, 170) + + +def _draw_red_cross( + frame: np.ndarray, + x_start: int, + width: int, + top_y: int, + height: int, +) -> None: + """Draw a red X across the given rectangle.""" + for d in range(-2, 3): + for step in range(height): + x1 = x_start + int(step * width / height) + y1 = top_y + step + d + if 0 <= y1 < H and 0 <= x1 < W: + frame[y1, x1] = (255, 80, 80) + y2 = top_y + height - step + d + if 0 <= y2 < H and 0 <= x1 < W: + frame[y2, x1] = (255, 80, 80) + + +def _make_fcn_frame(t: float) -> np.ndarray: + """Render a single FCN comparison frame.""" + frame = np.zeros((H, W, 3), dtype=np.uint8) + frame[:] = BG_COLOR + progress = min(t / (STEP_DUR * 0.8), 1.0) + + top_y = 140 + blocks_classic = [ + ((80, top_y), (70, 50), (70, 130, 200)), + ((170, top_y), (50, 40), (50, 100, 160)), + ((240, top_y), (60, 50), (70, 130, 200)), + ((320, top_y), (40, 35), (50, 100, 160)), + ((385, top_y), (55, 50), (160, 80, 60)), + ((465, top_y), (55, 50), (180, 60, 60)), + ((545, top_y), (80, 50), (200, 80, 80)), + ] + n_top = min(int(progress * 7) + 1, 7) + arrow_limit = 6 + _draw_pipeline_blocks(frame, blocks_classic, n_top, arrow_limit) + + cross_phase = 0.6 + if progress > cross_phase: + _draw_red_cross(frame, 385, 135, top_y, 50) + + bot_y = 380 + blocks_fcn = [ + ((80, bot_y), (70, 50), (70, 130, 200)), + ((170, bot_y), (50, 40), (50, 100, 160)), + ((240, bot_y), (60, 50), (70, 130, 200)), + ((320, bot_y), (40, 35), (50, 100, 160)), + ((385, bot_y), (70, 50), (80, 200, 120)), + ((480, bot_y), (75, 50), (200, 160, 80)), + ((580, bot_y), (80, 50), (100, 200, 100)), + ] + fcn_phase = 0.4 + if progress > fcn_phase: + n_bot = min(int((progress - fcn_phase) / 0.6 * 7) + 1, 7) + _draw_pipeline_blocks(frame, blocks_fcn, n_bot, arrow_limit) + + return frame + + +def _fcn_demo() -> list[CompositeVideoClip]: + """Animate FCN step-by-step: FC → Conv 1x1 transformation.""" + dur = STEP_DUR + 1 + fcn_clip = VideoClip(_make_fcn_frame, duration=dur).with_fps(FPS) + labels = [ + ("FCN: Fully Convolutional Network (2015)", 26, "#FFE082", FONT_B, (80, 20)), + ("KROK 1: Zamień FC → Conv 1x1", 18, "#A5D6A7", FONT_R, (80, 60)), + ("Klasyczny CNN:", 16, "#EF9A9A", FONT_B, (80, 105)), + ("Conv", 11, "white", FONT_R, (92, 148)), + ("Pool", 11, "white", FONT_R, (178, 148)), + ("Conv", 11, "white", FONT_R, (250, 148)), + ("Pool", 11, "white", FONT_R, (325, 148)), + ("Flatten", 11, "#EF9A9A", FONT_R, (390, 148)), + ("FC", 11, "#EF9A9A", FONT_R, (480, 148)), + ("1 label", 11, "#EF9A9A", FONT_R, (555, 148)), + ("FCN:", 16, "#A5D6A7", FONT_B, (80, 350)), + ("Conv", 11, "white", FONT_R, (92, 388)), + ("Pool", 11, "white", FONT_R, (178, 388)), + ("Conv", 11, "white", FONT_R, (250, 388)), + ("Pool", 11, "white", FONT_R, (325, 388)), + ("Conv1x1", 11, "#A5D6A7", FONT_R, (390, 388)), + ("Upsample", 11, "#FFE082", FONT_R, (486, 388)), + ("Mapa", 11, "#A5D6A7", FONT_R, (595, 388)), + ( + "FC: spłaszcza 3D→1D, wymusza stały rozmiar → 1 etykieta", + 16, + "#EF9A9A", + FONT_R, + (80, 250), + ), + ( + "Conv1x1: działa per piksel x kanały → DOWOLNY rozmiar → mapa klasy", + 16, + "#A5D6A7", + FONT_R, + (80, 460), + ), + ( + "KROK 2: Skip connections — łączą wczesne detale z późną abstrakcją", + 17, + "#64B5F6", + FONT_R, + (80, 510), + ), + ( + "Wczesne warstwy = krawędzie, tekstury | Późne = koncepty obiektów", + 15, + "#78909C", + FONT_R, + (80, 545), + ), + ( + "FCN = PIERWSZA sieć end-to-end do segmentacji per-piksel!", + 18, + "white", + FONT_R, + (80, 590), + ), + ( + "Mnemonik: FC → Conv 1x1 = otwieramy bramkę dla DOWOLNEGO rozmiaru", + 16, + "#FFE082", + FONT_R, + (80, 640), + ), + ] + slides = [_compose_slide(fcn_clip, labels, dur)] + + # Slide 2: FCN skip connections step by step + skip_lines = [ + ("FCN: Skip Connections — krok po kroku", 26, "#FFE082", FONT_B, (80, 30)), + ( + "1. Encoder zmniejsza: 224→112→56→28→14 (pooling)", + 18, + "#64B5F6", + FONT_R, + (100, 100), + ), + ( + " Każdy pooling traci detale przestrzenne (dokładne krawędzie)", + 15, + "#78909C", + FONT_R, + (100, 135), + ), + ( + "2. Decoder powiększa: 14→28→56→112→224 (upsample/deconv)", + 18, + "#A5D6A7", + FONT_R, + (100, 190), + ), + ( + " Upsample ODGADUJE piksele — rozmyty wynik!", + 15, + "#78909C", + FONT_R, + (100, 225), + ), + ( + "3. Skip connections: dodaj cechy z encodera do decodera", + 18, + "#FFE082", + FONT_R, + (100, 280), + ), + ( + " Wczesne cechy = GDZIE (precyzyjne krawędzie)", + 15, + "#64B5F6", + FONT_R, + (100, 315), + ), + ( + " Późne cechy = CO (abstrakcyjne koncepty)", + 15, + "#A5D6A7", + FONT_R, + (100, 345), + ), + ( + " Skip = daje decoderowi OBA → ostry wynik!", + 15, + "#FFE082", + FONT_R, + (100, 375), + ), + ( + "Warianty: FCN-32s (brak skip, rozmyty) → FCN-16s → FCN-8s (najlepszy)", + 16, + "#B0BEC5", + FONT_R, + (80, 440), + ), + ( + "FCN-32s: upsample 32x naraz → ROZMYTE granice", + 15, + "#EF9A9A", + FONT_R, + (100, 485), + ), + ( + "FCN-16s: skip z pool4 + upsample 16x → lepiej", + 15, + "#FFE082", + FONT_R, + (100, 520), + ), + ( + "FCN-8s: skip z pool3+pool4 + upsample 8x → OSTRE granice!", + 15, + "#A5D6A7", + FONT_R, + (100, 555), + ), + ( + "Im więcej skip connections → tym więcej " + "detali z encodera → ostrzejszy wynik", + 17, + "white", + FONT_R, + (80, 620), + ), + ] + slides.append(_text_slide(skip_lines, duration=STEP_DUR + 1)) + + return slides diff --git a/python_pkg/praca_magisterska_video/_q24_classical.py b/python_pkg/praca_magisterska_video/_q24_classical.py new file mode 100644 index 0000000..7c08a82 --- /dev/null +++ b/python_pkg/praca_magisterska_video/_q24_classical.py @@ -0,0 +1,332 @@ +"""Classical detection methods: detection concept, HOG+SVM, Viola-Jones.""" + +from __future__ import annotations + +from _q24_common import ( + BG_COLOR, + FONT_B, + FONT_R, + FPS, + STEP_DUR, + H, + W, + _tc, +) +from moviepy import CompositeVideoClip, VideoClip +from moviepy.video.fx import FadeIn, FadeOut +import numpy as np + + +# ── Detection concept ──────────────────────────────────────────── +def _detection_concept() -> list[CompositeVideoClip]: + """Show what detection is: bounding box + class + confidence.""" + slides = [] + + def make_det_frame(_t: float) -> np.ndarray: + frame = np.zeros((H, W, 3), dtype=np.uint8) + frame[:] = BG_COLOR + + # Draw a "scene" with colored rectangles representing objects + # Sky background area + frame[140:500, 100:700] = (40, 50, 70) + + # "Car" object + frame[350:430, 150:320] = (180, 60, 60) + # "Person" object + frame[280:440, 450:520] = (60, 120, 180) + # "Tree" object + frame[200:400, 580:650] = (40, 130, 50) + + # Bounding boxes (with labels drawn as colored borders) + # Car bbox + for thickness in range(3): + t = thickness + frame[348 - t : 432 + t, 148 - t : 148 - t + 2] = (255, 80, 80) + frame[348 - t : 432 + t, 322 + t - 2 : 322 + t] = (255, 80, 80) + frame[348 - t : 348 - t + 2, 148 - t : 322 + t] = (255, 80, 80) + frame[432 + t - 2 : 432 + t, 148 - t : 322 + t] = (255, 80, 80) + + # Person bbox + for thickness in range(3): + t = thickness + frame[278 - t : 442 + t, 448 - t : 448 - t + 2] = (80, 180, 255) + frame[278 - t : 442 + t, 522 + t - 2 : 522 + t] = (80, 180, 255) + frame[278 - t : 278 - t + 2, 448 - t : 522 + t] = (80, 180, 255) + frame[442 + t - 2 : 442 + t, 448 - t : 522 + t] = (80, 180, 255) + + # Tree bbox + for thickness in range(3): + t = thickness + frame[198 - t : 402 + t, 578 - t : 578 - t + 2] = (80, 220, 100) + frame[198 - t : 402 + t, 652 + t - 2 : 652 + t] = (80, 220, 100) + frame[198 - t : 198 - t + 2, 578 - t : 652 + t] = (80, 220, 100) + frame[402 + t - 2 : 402 + t, 578 - t : 652 + t] = (80, 220, 100) + + # Comparison boxes on right side + # Classification + frame[180:260, 800:1150] = (35, 45, 65) + # Detection + frame[290:370, 800:1150] = (35, 45, 65) + # Segmentation + frame[400:480, 800:1150] = (35, 45, 65) + + return frame + + det_clip = VideoClip(make_det_frame, duration=STEP_DUR).with_fps(FPS) + text_clips: list[VideoClip] = [det_clip] + labels = [ + ("Detekcja obiektów — co to jest?", 28, "#FFE082", FONT_B, (100, 20)), + ("Wynik: (klasa, bounding box, pewność)", 20, "#B0BEC5", FONT_R, (100, 65)), + ("samochód 95%", 14, "#EF9A9A", FONT_B, (150, 340)), + ("osoba 88%", 14, "#64B5F6", FONT_B, (450, 268)), + ("drzewo 72%", 14, "#A5D6A7", FONT_B, (580, 188)), + ("Klasyfikacja: cały obraz → 1 etykieta", 15, "#78909C", FONT_R, (810, 210)), + ("Detekcja: bbox + klasa + pewność", 15, "#FFE082", FONT_R, (810, 320)), + ("Segmentacja: maska per piksel", 15, "#78909C", FONT_R, (810, 430)), + ("← granulacja rośnie →", 14, "#90CAF9", FONT_R, (810, 520)), + ] + for text, fs, color, font, pos in labels: + tc = ( + _tc(text=text, font_size=fs, color=color, font=font) + .with_duration(STEP_DUR) + .with_position(pos) + ) + text_clips.append(tc) + + slides.append( + CompositeVideoClip(text_clips, size=(W, H)).with_effects( + [FadeIn(0.3), FadeOut(0.3)] + ) + ) + return slides + + +# ── HOG + SVM pipeline ─────────────────────────────────────────── +def _hog_svm_demo() -> list[CompositeVideoClip]: + """Animate HOG feature computation and SVM classification.""" + slides = [] + + def make_hog_frame(t: float) -> np.ndarray: + frame = np.zeros((H, W, 3), dtype=np.uint8) + frame[:] = BG_COLOR + + progress = min(t / (STEP_DUR * 0.8), 1.0) + + # Pipeline stages as boxes with arrows + stages = [ + ("Gradient", (80, 250), (130, 80), (100, 160, 220)), + ("Orientacja", (260, 250), (130, 80), (80, 180, 140)), + ("Komórki 8x8", (440, 250), (130, 80), (200, 160, 80)), + ("Bloki 2x2", (620, 250), (130, 80), (200, 120, 60)), + ("Normalizacja", (800, 250), (130, 80), (180, 100, 80)), + ("SVM", (980, 250), (130, 80), (220, 80, 80)), + ] + + n_active = int(progress * len(stages)) + 1 + + for i, (_label, (sx, sy), (sw, sh), color) in enumerate(stages): + if i < n_active: + frame[sy : sy + sh, sx : sx + sw] = color + # Border + frame[sy : sy + 2, sx : sx + sw] = tuple( + min(c + 60, 255) for c in color + ) + frame[sy + sh - 2 : sy + sh, sx : sx + sw] = tuple( + min(c + 60, 255) for c in color + ) + + # Arrow to next + if i < len(stages) - 1: + ax = sx + sw + 5 + ay = sy + sh // 2 + frame[ay - 1 : ay + 2, ax : ax + 20] = (150, 150, 170) + + # Show gradient computation example at bottom + gradient_phase = 0.2 + if progress > gradient_phase: + # Mini pixel grid showing gradient computation + gx, gy = 100, 430 + pixels = [50, 50, 200] + for idx, val in enumerate(pixels): + x = gx + idx * 50 + frame[gy : gy + 40, x : x + 40] = (val, val, val) + + return frame + + hog_clip = VideoClip(make_hog_frame, duration=STEP_DUR).with_fps(FPS) + text_clips: list[VideoClip] = [hog_clip] + labels = [ + ("HOG + SVM — pipeline detekcji pieszych", 28, "#FFE082", FONT_B, (80, 20)), + ( + "Mnemonik: GOKBN = Gradienty→Orientacja→Komórki→Bloki→Normalizacja", + 16, + "#A5D6A7", + FONT_R, + (80, 65), + ), + ("Gradient: siła i kierunek zmiany jasności", 14, "#64B5F6", FONT_R, (80, 95)), + ( + "Histogram: 9 binów (0°-180°, co 20°) per komórka 8x8", + 14, + "#78909C", + FONT_R, + (80, 120), + ), + ( + "[50][50][200] → Gx = 200-50 = 150 = silna krawędź!", + 16, + "#EF9A9A", + FONT_R, + (80, 490), + ), + ( + "Wektor HOG (3780 cech) → SVM: pieszy (+1) / tło (-1)", + 16, + "white", + FONT_R, + (80, 540), + ), + ( + "Sliding window 64x128 przesuwa się po obrazie → NMS → wynik", + 16, + "#90CAF9", + FONT_R, + (80, 580), + ), + ( + "SVM = LINIA MAKSYMALNEGO ODDECHU (max margines, support vectors)", + 16, + "#FFE082", + FONT_R, + (80, 620), + ), + ] + for text, fs, color, font, pos in labels: + tc = ( + _tc(text=text, font_size=fs, color=color, font=font) + .with_duration(STEP_DUR) + .with_position(pos) + ) + text_clips.append(tc) + + slides.append( + CompositeVideoClip(text_clips, size=(W, H)).with_effects( + [FadeIn(0.3), FadeOut(0.3)] + ) + ) + return slides + + +# ── Viola-Jones ─────────────────────────────────────────────────── +def _viola_jones_demo() -> list[CompositeVideoClip]: + """Animate Viola-Jones cascade concept.""" + slides = [] + + def make_cascade_frame(t: float) -> np.ndarray: + frame = np.zeros((H, W, 3), dtype=np.uint8) + frame[:] = BG_COLOR + + progress = min(t / (STEP_DUR * 0.8), 1.0) + + # Draw cascade "funnel" — stages filtering out non-faces + stages = 5 + start_width = 1000 + start_count = 10000 + x_center = W // 2 + + for i in range(stages): + stage_progress = min(progress * stages - i, 1.0) + if stage_progress <= 0: + break + + width = int(start_width * (1 - i * 0.18)) + int(start_count * (0.3**i)) + y = 150 + i * 100 + h_box = 60 + + # Stage box + x1 = x_center - width // 2 + frame[y : y + h_box, x1 : x1 + width] = ( + 50 + i * 10, + 60 + i * 10, + 80 + i * 10, + ) + # Border + frame[y : y + 2, x1 : x1 + width] = (100 + i * 20, 130 + i * 15, 200) + frame[y + h_box - 2 : y + h_box, x1 : x1 + width] = ( + 100 + i * 20, + 130 + i * 15, + 200, + ) + + # Arrow down to next + if i < stages - 1: + frame[y + h_box + 5 : y + h_box + 25, x_center - 1 : x_center + 2] = ( + 150, + 150, + 170, + ) + + # Red "rejected" arrows on sides + if i > 0: + # Left reject arrow + rx = x1 - 30 + ry = y + h_box // 2 + frame[ry - 1 : ry + 2, rx : rx + 25] = (200, 80, 80) + + return frame + + cascade_clip = VideoClip(make_cascade_frame, duration=STEP_DUR).with_fps(FPS) + text_clips: list[VideoClip] = [cascade_clip] + labels = [ + ( + "Viola-Jones — kaskada klasyfikatorów (2001)", + 28, + "#FFE082", + FONT_B, + (80, 20), + ), + ( + "3 innowacje: HIC = Haar + Integral Image + Cascade", + 20, + "#B0BEC5", + FONT_R, + (80, 65), + ), + ("Etap 1: 2 cechy Haar", 14, "#64B5F6", FONT_R, (170, 170)), + ("Etap 2: 10 cech", 14, "#64B5F6", FONT_R, (210, 270)), + ("Etap 3: 25 cech", 14, "#64B5F6", FONT_R, (240, 370)), + ("Etap 4: 50 cech", 14, "#64B5F6", FONT_R, (260, 470)), + ("→ TWARZ!", 16, "#A5D6A7", FONT_B, (590, 560)), + ( + "SITO: 99% okien odpada w pierwszych 3 etapach → REAL-TIME!", + 16, + "#EF9A9A", + FONT_R, + (80, 620), + ), + ( + "Haar: kontrast jasna/ciemna | Integral Image: " + "suma prostokąta O(1) = 4 odczyty", + 14, + "#78909C", + FONT_R, + (80, 655), + ), + ("odrzucone →", 12, "#EF9A9A", FONT_R, (60, 275)), + ("odrzucone →", 12, "#EF9A9A", FONT_R, (60, 375)), + ] + for text, fs, color, font, pos in labels: + tc = ( + _tc(text=text, font_size=fs, color=color, font=font) + .with_duration(STEP_DUR) + .with_position(pos) + ) + text_clips.append(tc) + + slides.append( + CompositeVideoClip(text_clips, size=(W, H)).with_effects( + [FadeIn(0.3), FadeOut(0.3)] + ) + ) + return slides diff --git a/python_pkg/praca_magisterska_video/_q24_common.py b/python_pkg/praca_magisterska_video/_q24_common.py new file mode 100644 index 0000000..1f6d0ac --- /dev/null +++ b/python_pkg/praca_magisterska_video/_q24_common.py @@ -0,0 +1,115 @@ +"""Shared constants and helpers for Q24 object detection visualization.""" + +from __future__ import annotations + +import logging +import os +from pathlib import Path + +import numpy as np + +os.environ["FFMPEG_BINARY"] = "/usr/bin/ffmpeg" + +from moviepy import ( + ColorClip, + CompositeVideoClip, + TextClip, + VideoClip, +) +from moviepy.video.fx import FadeIn, FadeOut + +# ── Constants ───────────────────────────────────────────────────── +W, H = 1280, 720 +FPS = 24 +STEP_DUR = 7.0 +HEADER_DUR = 4.0 +FONT_B = "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf" +FONT_R = "/usr/share/fonts/TTF/DejaVuSans.ttf" +OUTPUT_DIR = Path(__file__).resolve().parent / "videos" +OUTPUT_DIR.mkdir(parents=True, exist_ok=True) +OUTPUT = str(OUTPUT_DIR / "q24_object_detection.mp4") + +BG_COLOR = (15, 20, 35) + +_logger = logging.getLogger(__name__) + +# Re-export numpy for sub-modules that need it alongside constants. +__all__ = [ + "BG_COLOR", + "FONT_B", + "FONT_R", + "FPS", + "HEADER_DUR", + "OUTPUT", + "OUTPUT_DIR", + "STEP_DUR", + "H", + "W", + "_logger", + "_make_header", + "_tc", + "_text_slide", + "np", +] + + +def _tc(**kwargs: object) -> TextClip: + """TextClip wrapper that adds enough bottom margin to prevent clipping.""" + fs = kwargs.get("font_size", 24) + m = int(fs) // 3 + 2 + kwargs["margin"] = (0, m) + return TextClip(**kwargs) + + +def _make_header( + title: str, subtitle: str, duration: float = HEADER_DUR +) -> CompositeVideoClip: + """Create a title/subtitle header slide.""" + bg = ColorClip(size=(W, H), color=BG_COLOR).with_duration(duration) + t = ( + _tc( + text=title, + font_size=48, + color="white", + font=FONT_B, + ) + .with_duration(duration) + .with_position(("center", 260)) + ) + s = ( + _tc( + text=subtitle, + font_size=24, + color="#90CAF9", + font=FONT_R, + ) + .with_duration(duration) + .with_position(("center", 340)) + ) + return CompositeVideoClip([bg, t, s], size=(W, H)).with_effects( + [FadeIn(0.5), FadeOut(0.5)] + ) + + +def _text_slide( + lines: list[tuple[str, int, str, str, tuple[str | int, str | int]]], + duration: float = STEP_DUR, +) -> CompositeVideoClip: + """Create a text-only slide from a list of (text, size, color, font, pos).""" + bg = ColorClip(size=(W, H), color=BG_COLOR).with_duration(duration) + clips: list[VideoClip] = [bg] + for text, font_size, color, font, pos in lines: + tc = ( + _tc( + text=text, + font_size=font_size, + color=color, + font=font, + ) + .with_duration(duration) + .with_position(pos) + ) + clips.append(tc) + return CompositeVideoClip(clips, size=(W, H)).with_effects( + [FadeIn(0.3), FadeOut(0.3)] + ) diff --git a/python_pkg/praca_magisterska_video/_q24_nms_final.py b/python_pkg/praca_magisterska_video/_q24_nms_final.py new file mode 100644 index 0000000..5987566 --- /dev/null +++ b/python_pkg/praca_magisterska_video/_q24_nms_final.py @@ -0,0 +1,239 @@ +"""NMS/IoU, detector-from-classifier, and methods comparison.""" + +from __future__ import annotations + +from _q24_common import ( + BG_COLOR, + FONT_B, + FONT_R, + FPS, + STEP_DUR, + H, + W, + _tc, + _text_slide, +) +from moviepy import ColorClip, CompositeVideoClip, VideoClip +from moviepy.video.fx import FadeIn, FadeOut +import numpy as np + + +# ── NMS + IoU ───────────────────────────────────────────────────── +def _nms_iou_demo() -> list[CompositeVideoClip]: + """Animate NMS and IoU concepts.""" + slides = [] + + def make_nms_frame(t: float) -> np.ndarray: + frame = np.zeros((H, W, 3), dtype=np.uint8) + frame[:] = BG_COLOR + + progress = min(t / (STEP_DUR * 0.7), 1.0) + + # Draw overlapping bounding boxes + ox, oy = 100, 200 + obj_w, obj_h = 150, 120 + + # Multiple overlapping detections for same object + boxes = [ + (ox, oy, obj_w, obj_h, 0.95, (255, 80, 80)), # best + (ox + 15, oy - 10, obj_w + 10, obj_h + 5, 0.90, (200, 60, 60)), + (ox - 10, oy + 5, obj_w - 5, obj_h + 10, 0.85, (160, 50, 50)), + ] + # Different object far away + boxes.append((ox + 350, oy + 50, 100, 100, 0.40, (80, 180, 255))) + + for i, (bx, by, bw, bh, _conf, color) in enumerate(boxes): + dc = color + nms_phase = 0.4 + nms_limit = 3 + if progress > nms_phase and i > 0 and i < nms_limit: + # After NMS, these get removed (shown as faded/crossed) + dc = (60, 40, 40) + + for tt in range(2): + frame[by - tt : by + bh + tt, bx - tt : bx - tt + 2] = dc + frame[by - tt : by + bh + tt, bx + bw + tt - 2 : bx + bw + tt] = dc + frame[by - tt : by - tt + 2, bx - tt : bx + bw + tt] = dc + frame[by + bh + tt - 2 : by + bh + tt, bx - tt : bx + bw + tt] = dc + + # IoU visualization on right side + iou_x, iou_y = 700, 200 + # Box A + frame[iou_y : iou_y + 100, iou_x : iou_x + 100] = (80, 80, 200) + # Box B (overlapping) + frame[iou_y + 40 : iou_y + 140, iou_x + 40 : iou_x + 140] = (200, 80, 80) + # Intersection highlighted + frame[iou_y + 40 : iou_y + 100, iou_x + 40 : iou_x + 100] = (200, 150, 200) + + return frame + + nms_clip = VideoClip(make_nms_frame, duration=STEP_DUR).with_fps(FPS) + text_clips: list[VideoClip] = [nms_clip] + labels = [ + ("NMS (Non-Maximum Suppression) + IoU", 28, "#FFE082", FONT_B, (80, 20)), + ( + "NMS = Najlepszy Ma Się dobrze — zachowaj najlepszą, usuń duplikaty", + 18, + "#B0BEC5", + FONT_R, + (80, 65), + ), + ("conf=0.95 ✓", 14, "#A5D6A7", FONT_B, (100, 340)), + ("0.90 ✗ IoU>0.5", 13, "#EF9A9A", FONT_R, (100, 365)), + ("0.85 ✗ IoU>0.5", 13, "#EF9A9A", FONT_R, (100, 390)), + ("0.40 ✓ INNY obiekt", 13, "#64B5F6", FONT_R, (100, 420)), + ("IoU = Intersection over Union", 18, "#FFE082", FONT_B, (700, 160)), + ("IoU = pole(∩) / pole(AUB)", 16, "white", FONT_R, (700, 380)), + ("Fioletowy = intersection", 14, "#CE93D8", FONT_R, (700, 410)), + ("IoU > 0.5 → TEN SAM obiekt → usuń", 14, "#EF9A9A", FONT_R, (700, 440)), + ("IoU < 0.5 → INNY obiekt → zachowaj", 14, "#A5D6A7", FONT_R, (700, 470)), + ( + "DETR: jedyny detektor BEZ NMS (Hungarian matching zamiast tego)", + 14, + "#78909C", + FONT_R, + (80, 620), + ), + ] + for text, fs, color, font, pos in labels: + tc = ( + _tc(text=text, font_size=fs, color=color, font=font) + .with_duration(STEP_DUR) + .with_position(pos) + ) + text_clips.append(tc) + + slides.append( + CompositeVideoClip(text_clips, size=(W, H)).with_effects( + [FadeIn(0.3), FadeOut(0.3)] + ) + ) + return slides + + +# ── Detector from Classifier ───────────────────────────────────── +def _detector_from_classifier() -> list[CompositeVideoClip]: + """Show 3 approaches to building a detector from a classifier.""" + slides = [] + + approaches = [ + ( + "Podejście 1: Sliding Window (NAJWOLNIEJSZE)", + [ + ("Okno przesuwa się po obrazie w wielu skalach", "#B0BEC5"), + ("Każde okno → klasyfikator (np. ResNet) → klasa + pewność", "#B0BEC5"), + ("~18 000 okien x 10ms = ~3 minuty na obraz!", "#EF9A9A"), + ("Mnemonik: WYCINAJ i PYTAJ — jak wycinanie ciasteczek", "#FFE082"), + ], + "SRF", + ), + ( + "Podejście 2: Region Proposals (= R-CNN)", + [ + ("Selective Search → ~2000 inteligentnych regionów", "#B0BEC5"), + ("Każdy region → CNN → wektor cech → SVM klasyfikuje", "#B0BEC5"), + ("~2000 x 10ms = ~20 sec — 9x szybciej!", "#64B5F6"), + ( + "Mnemonik: INTELIGENTNE CIĘCIE — wytnij tylko tam gdzie wiśnie", + "#FFE082", + ), + ], + "SRF", + ), + ( + "Podejście 3: Fine-tune backbone (NAJLEPSZE)", + [ + ( + "Pretrained backbone (ResNet) → odetnij FC → dodaj detection head", + "#B0BEC5", + ), + ( + "Detection head = głowica klasyfikacji + głowica regresji bbox", + "#B0BEC5", + ), + ("~0.2 sec/obraz, najlepsza jakość (mAP ~42%)", "#A5D6A7"), + ("Mnemonik: PRZESZCZEP GŁOWY — ten sam silnik, nowa głowa", "#FFE082"), + ], + "SRF", + ), + ] + + for title, points, _mnem in approaches: + lines = [ + (title, 24, "#FFE082", FONT_B, (80, 140)), + ] + for i, (text, color) in enumerate(points): + lines.append((f"• {text}", 18, color, FONT_R, (100, 220 + i * 50))) + + lines.append( + ( + "Detektor z klasyfikatora: SRF = Sliding → Region → Fine-tune", + 16, + "#78909C", + FONT_R, + (80, 520), + ) + ) + lines.append( + ( + "= Szukaj Ręcznie, Finalnie optymalizuj!", + 16, + "#90CAF9", + FONT_R, + (80, 550), + ) + ) + + slides.append(_text_slide(lines, duration=STEP_DUR)) + + return slides + + +# ── Methods comparison ──────────────────────────────────────────── +def _methods_comparison() -> CompositeVideoClip: + """Create a comparison table of all detection methods.""" + bg = ColorClip(size=(W, H), color=BG_COLOR).with_duration(10.0) + title = ( + _tc( + text="Porównanie detektorów", + font_size=36, + color="white", + font=FONT_B, + ) + .with_duration(10.0) + .with_position(("center", 20)) + ) + + rows = [ + ("Model", "Rok", "Typ", "Szybkość", "Kluczowe"), + ("HOG+SVM", "2005", "Klasyczny", "~1 fps", "Gradient histogramy"), + ("Viola-Jones", "2001", "Klasyczny", "30+ fps", "Haar+Cascade"), + ("R-CNN", "2014", "Two-stage", "50 sec!", "CNN per region"), + ("Fast R-CNN", "2015", "Two-stage", "2 sec", "ROI Pooling"), + ("Faster R-CNN", "2015", "Two-stage", "5 fps", "RPN w sieci"), + ("YOLO", "2016", "One-stage", "45+ fps", "Siatka SxS"), + ("DETR", "2020", "Transformer", "~40 fps", "Bez NMS!"), + ] + + clips: list[VideoClip] = [bg, title] + for i, row in enumerate(rows): + y_pos = 75 + i * 72 + col_x = [40, 200, 280, 400, 530] + for j, cell in enumerate(row): + fs = 16 if i > 0 else 18 + color = "#64B5F6" if i == 0 else "#E0E0E0" + tc = ( + _tc( + text=cell, + font_size=fs, + color=color, + font=FONT_B if i == 0 else FONT_R, + ) + .with_duration(10.0) + .with_position((col_x[j], y_pos)) + ) + clips.append(tc) + + return CompositeVideoClip(clips, size=(W, H)).with_effects( + [FadeIn(0.5), FadeOut(0.5)] + ) diff --git a/python_pkg/praca_magisterska_video/_q24_rcnn.py b/python_pkg/praca_magisterska_video/_q24_rcnn.py new file mode 100644 index 0000000..34730c2 --- /dev/null +++ b/python_pkg/praca_magisterska_video/_q24_rcnn.py @@ -0,0 +1,405 @@ +"""R-CNN family: evolution, detailed pipeline, ROI pooling.""" + +from __future__ import annotations + +from _q24_common import ( + BG_COLOR, + FONT_B, + FONT_R, + FPS, + STEP_DUR, + H, + W, + _tc, +) +from moviepy import CompositeVideoClip, VideoClip +from moviepy.video.fx import FadeIn, FadeOut +import numpy as np + + +# ── R-CNN Evolution ─────────────────────────────────────────────── +def _rcnn_evolution() -> list[CompositeVideoClip]: + """Animate R-CNN → Fast R-CNN → Faster R-CNN evolution.""" + slides = [] + + def make_evolution_frame(t: float) -> np.ndarray: + frame = np.zeros((H, W, 3), dtype=np.uint8) + frame[:] = BG_COLOR + + progress = min(t / (STEP_DUR * 0.8), 1.0) + + # Three rows: R-CNN, Fast R-CNN, Faster R-CNN + models = [ + ( + "R-CNN (2014)", + 50, + [ + ("Selective\nSearch", (200, 150), (100, 50), (120, 100, 60)), + ("2000x\nCNN", (350, 150), (80, 50), (180, 60, 60)), + ("2000x\nSVM", (480, 150), (80, 50), (180, 60, 60)), + ("NMS", (610, 150), (60, 50), (100, 140, 100)), + ], + "50 sec/obraz!", + ), + ( + "Fast R-CNN (2015)", + 300, + [ + ("Selective\nSearch", (200, 150), (100, 50), (120, 100, 60)), + ("1x CNN\n(cały obraz)", (350, 150), (100, 50), (80, 140, 200)), + ("ROI Pool\n(2000)", (500, 150), (90, 50), (200, 160, 80)), + ("FC", (640, 150), (50, 50), (100, 140, 100)), + ], + "2 sec/obraz", + ), + ( + "Faster R-CNN (2015)", + 300, + [ + ("CNN\nbackbone", (200, 150), (90, 50), (80, 140, 200)), + ("RPN\n(~300)", (340, 150), (80, 50), (200, 120, 60)), + ("ROI Pool", (470, 150), (80, 50), (200, 160, 80)), + ("FC", (600, 150), (50, 50), (100, 140, 100)), + ], + "0.2 sec → 5 fps!", + ), + ] + + n_models = int(progress * 3) + 1 + + for mi, (_name, base_y, stages, _speed) in enumerate(models): + if mi >= n_models: + break + for _label, (bx, by_off), (bw, bh), color in stages: + by = base_y + by_off - 150 + frame[by : by + bh, bx : bx + bw] = color + frame[by : by + 2, bx : bx + bw] = tuple( + min(c + 50, 255) for c in color + ) + frame[by + bh - 2 : by + bh, bx : bx + bw] = tuple( + min(c + 50, 255) for c in color + ) + + # Arrows between stages + for si in range(len(stages) - 1): + sx = stages[si][1][0] + stages[si][2][0] + ex = stages[si + 1][1][0] + ay = base_y + 25 + frame[ay - 1 : ay + 2, sx + 3 : ex - 3] = (150, 150, 170) + + return frame + + evo_clip = VideoClip(make_evolution_frame, duration=STEP_DUR + 1).with_fps(FPS) + text_clips: list[VideoClip] = [evo_clip] + labels = [ + ("Ewolucja R-CNN — CORAZ MNIEJ MARNOWANIA", 28, "#FFE082", FONT_B, (80, 20)), + ("R-CNN (2014)", 20, "#EF9A9A", FONT_B, (50, 80)), + ("50 sec/obraz (2000x forward pass!)", 14, "#EF9A9A", FONT_R, (720, 100)), + ("Fast R-CNN (2015)", 20, "#64B5F6", FONT_B, (50, 330)), + ("2 sec/obraz (CNN raz + ROI Pool)", 14, "#64B5F6", FONT_R, (720, 350)), + ("Faster R-CNN (2015)", 20, "#A5D6A7", FONT_B, (50, 580)), + ("0.2 sec → 5 fps (RPN w sieci!)", 14, "#A5D6A7", FONT_R, (720, 600)), + ( + "Kluczowe innowacje: ROI Pooling → stały rozmiar " + "| RPN → propozycje w sieci", + 14, + "#78909C", + FONT_R, + (80, 660), + ), + ] + for text, fs, color, font, pos in labels: + tc = ( + _tc(text=text, font_size=fs, color=color, font=font) + .with_duration(STEP_DUR + 1) + .with_position(pos) + ) + text_clips.append(tc) + + slides.append( + CompositeVideoClip(text_clips, size=(W, H)).with_effects( + [FadeIn(0.3), FadeOut(0.3)] + ) + ) + return slides + + +# ── R-CNN Detailed Pipeline ────────────────────────────────────── +def _rcnn_detailed() -> list[CompositeVideoClip]: + """Animate R-CNN step-by-step pipeline in detail.""" + slides = [] + + # Slide 1: R-CNN pipeline step by step + def make_rcnn_pipeline(t: float) -> np.ndarray: + frame = np.zeros((H, W, 3), dtype=np.uint8) + frame[:] = BG_COLOR + progress = min(t / (STEP_DUR * 0.8), 1.0) + + # Step boxes arranged vertically with arrows + steps = [ + ((80, 130), (200, 55), (120, 100, 60), "1. Selective Search"), + ((80, 230), (200, 55), (180, 60, 60), "2. Wytnij 2000 regionów"), + ((80, 330), (200, 55), (70, 130, 200), "3. CNN per region"), + ((80, 430), (200, 55), (200, 100, 80), "4. SVM klasyfikuje"), + ((80, 530), (200, 55), (100, 180, 100), "5. Bbox regresja + NMS"), + ] + n_steps = min(int(progress * 5) + 1, 5) + for i, ((bx, by), (bw, bh), color, _lbl) in enumerate(steps): + if i < n_steps: + frame[by : by + bh, bx : bx + bw] = color + frame[by : by + 2, bx : bx + bw] = tuple( + min(c + 50, 255) for c in color + ) + frame[by + bh - 2 : by + bh, bx : bx + bw] = tuple( + min(c + 50, 255) for c in color + ) + # Arrow down + arrow_limit = 4 + if i < arrow_limit: + ax = bx + bw // 2 + ay = by + bh + 5 + frame[ay : ay + 20, ax - 1 : ax + 2] = (150, 150, 170) + + # Illustration: many overlapping regions from Selective Search + overlay_phase = 0.2 + if progress > overlay_phase: + rng_local = np.random.default_rng(42) + n_boxes = min(int((progress - 0.2) * 15), 8) + for i in range(n_boxes): + rx = 500 + rng_local.integers(-30, 100) + ry = 200 + rng_local.integers(-20, 120) + rw = 60 + rng_local.integers(0, 80) + rh = 50 + rng_local.integers(0, 70) + c = (80 + i * 15, 100 + i * 10, 60 + i * 20) + for tt in range(2): + frame[ry - tt : ry + rh + tt, rx - tt : rx - tt + 2] = c + frame[ry - tt : ry + rh + tt, rx + rw + tt - 2 : rx + rw + tt] = c + frame[ry - tt : ry - tt + 2, rx - tt : rx + rw + tt] = c + frame[ry + rh + tt - 2 : ry + rh + tt, rx - tt : rx + rw + tt] = c + + return frame + + rcnn_clip = VideoClip(make_rcnn_pipeline, duration=STEP_DUR + 1).with_fps(FPS) + dur = STEP_DUR + 1 + labels = [ + ("R-CNN: krok po kroku (2014, Girshick)", 26, "#FFE082", FONT_B, (80, 20)), + ("Pipeline detekcji two-stage", 16, "#B0BEC5", FONT_R, (80, 60)), + ("Selective Search", 11, "white", FONT_R, (105, 145)), + ("2000 regionów", 11, "white", FONT_R, (105, 245)), + ("CNN per region", 11, "white", FONT_R, (105, 345)), + ("SVM klasyfikuje", 11, "white", FONT_R, (105, 445)), + ("Regresja + NMS", 11, "white", FONT_R, (105, 545)), + ("~2000 propozycji regionów", 14, "#78909C", FONT_R, (500, 155)), + ("(inteligentne łączenie", 13, "#78909C", FONT_R, (500, 180)), + ("podobnych fragmentów)", 13, "#78909C", FONT_R, (500, 200)), + ("Problem: 2000 x CNN forward pass", 16, "#EF9A9A", FONT_R, (400, 400)), + ("= 50 SEKUND na obraz!", 18, "#EF9A9A", FONT_B, (400, 430)), + ("CNN liczy cechy per region OSOBNO", 14, "#EF9A9A", FONT_R, (400, 470)), + ( + "→ regiony się nakładają → obliczenia się powtarzają!", + 14, + "#EF9A9A", + FONT_R, + (400, 495), + ), + ( + "Rozwiązanie: CNN raz na cały obraz → Fast R-CNN →", + 16, + "#A5D6A7", + FONT_R, + (80, 620), + ), + ] + text_clips: list[VideoClip] = [rcnn_clip] + for text, fs, color, font, pos in labels: + tc = ( + _tc(text=text, font_size=fs, color=color, font=font) + .with_duration(dur) + .with_position(pos) + ) + text_clips.append(tc) + slides.append( + CompositeVideoClip(text_clips, size=(W, H)).with_effects( + [FadeIn(0.3), FadeOut(0.3)] + ) + ) + + return slides + + +# ── ROI Pooling ────────────────────────────────────────────────── + + +def _draw_roi_pool_grid(frame: np.ndarray) -> None: + """Draw the 3x3 ROI pool grid with max-pooled feature values.""" + out_x, out_y = 400, 220 + out_cell = 50 + out_n = 3 + roi_r1, roi_c1 = 2, 1 + roi_r2, roi_c2 = 6, 5 + roi_h = roi_r2 - roi_r1 + roi_w = roi_c2 - roi_c1 + for r in range(out_n): + for c in range(out_n): + x = out_x + c * out_cell + y = out_y + r * out_cell + + # Compute the max from corresponding region + src_r1 = roi_r1 + r * roi_h // out_n + src_r2 = roi_r1 + (r + 1) * roi_h // out_n + src_c1 = roi_c1 + c * roi_w // out_n + src_c2 = roi_c1 + (c + 1) * roi_w // out_n + max_val = 0 + for sr in range(src_r1, src_r2): + for sc in range(src_c1, src_c2): + v = 30 + ((sr * 7 + sc * 13 + 42) % 40) + max_val = max(max_val, v) + + frame[y : y + out_cell - 2, x : x + out_cell - 2] = ( + max_val, + max_val + 20, + max_val + 40, + ) + frame[y : y + 2, x : x + out_cell - 2] = (80, 200, 120) + frame[y + out_cell - 4 : y + out_cell - 2, x : x + out_cell - 2] = ( + 80, + 200, + 120, + ) + + +def _make_roi_frame(t: float) -> np.ndarray: + """Render a single frame for the ROI pooling animation.""" + frame = np.zeros((H, W, 3), dtype=np.uint8) + frame[:] = BG_COLOR + progress = min(t / (STEP_DUR * 0.7), 1.0) + + # Left: feature map with ROI highlighted + fm_x, fm_y = 60, 180 + fm_cell = 30 + fm_grid = 8 + for r in range(fm_grid): + for c in range(fm_grid): + x = fm_x + c * fm_cell + y = fm_y + r * fm_cell + # Random-looking feature values + val = 30 + ((r * 7 + c * 13 + 42) % 40) + frame[y : y + fm_cell - 1, x : x + fm_cell - 1] = ( + val, + val + 10, + val + 20, + ) + + # ROI region highlighted + roi_r1, roi_c1 = 2, 1 + roi_r2, roi_c2 = 6, 5 + for tt in range(3): + ry1 = fm_y + roi_r1 * fm_cell - tt + ry2 = fm_y + roi_r2 * fm_cell + tt + rx1 = fm_x + roi_c1 * fm_cell - tt + rx2 = fm_x + roi_c2 * fm_cell + tt + frame[ry1:ry2, rx1 : rx1 + 2] = (255, 200, 50) + frame[ry1:ry2, rx2 - 2 : rx2] = (255, 200, 50) + frame[ry1 : ry1 + 2, rx1:rx2] = (255, 200, 50) + frame[ry2 - 2 : ry2, rx1:rx2] = (255, 200, 50) + + # Arrow + arrow_phase = 0.3 + if progress > arrow_phase: + frame[300:303, 310:380] = (150, 150, 170) + + # Middle: ROI divided into 3x3 grid (output_size) + grid_phase = 0.3 + if progress > grid_phase: + _draw_roi_pool_grid(frame) + + # Arrow to FC + fc_phase = 0.6 + if progress > fc_phase: + frame[300:303, 560:630] = (150, 150, 170) + # FC box + frame[270:340, 650:730] = (200, 100, 80) + frame[270:272, 650:730] = (240, 140, 120) + frame[338:340, 650:730] = (240, 140, 120) + + return frame + + +def _roi_pooling_demo() -> list[CompositeVideoClip]: + """Animate ROI Pooling: key Fast R-CNN innovation.""" + slides = [] + + roi_clip = VideoClip(_make_roi_frame, duration=STEP_DUR + 1).with_fps(FPS) + dur = STEP_DUR + 1 + labels = [ + ("ROI Pooling: kluczowa innowacja Fast R-CNN", 26, "#FFE082", FONT_B, (80, 20)), + ( + "KROK 1: CNN raz na CAŁY obraz → feature mapa", + 17, + "#64B5F6", + FONT_R, + (80, 60), + ), + ( + "KROK 2: Wytnij ROI z feature mapy (nie z obrazu!)", + 17, + "#FFE082", + FONT_R, + (80, 90), + ), + ( + "KROK 3: Siatkuj ROI na 3x3 → max pool per komórka → stały rozmiar", + 17, + "#A5D6A7", + FONT_R, + (80, 120), + ), + ("Feature mapa", 14, "#64B5F6", FONT_B, (60, 160)), + ("ROI (żółta ramka)", 13, "#FFE082", FONT_R, (60, 440)), + ("ROI Pool 3x3", 14, "#A5D6A7", FONT_B, (400, 195)), + ("(max z komórki)", 13, "#78909C", FONT_R, (400, 380)), + ("FC", 14, "white", FONT_B, (670, 280)), + ( + "Problem: ROI mają RÓŻNE rozmiary, FC wymaga STAŁEGO", + 15, + "#B0BEC5", + FONT_R, + (80, 500), + ), + ( + "ROI Pooling: dzieli ROI na siatkę, max pool → STAŁY rozmiar!", + 16, + "white", + FONT_R, + (80, 535), + ), + ( + "Fast R-CNN: CNN raz → 1 feature mapa → " + "ROI Pool 2000 regionów → 25x szybciej!", + 16, + "#A5D6A7", + FONT_R, + (80, 580), + ), + ( + "(R-CNN: 2000x CNN = 50s | Fast R-CNN: 1xCNN + ROI Pool = 2s)", + 15, + "#EF9A9A", + FONT_R, + (80, 620), + ), + ] + text_clips: list[VideoClip] = [roi_clip] + for text, fs, color, font, pos in labels: + tc = ( + _tc(text=text, font_size=fs, color=color, font=font) + .with_duration(dur) + .with_position(pos) + ) + text_clips.append(tc) + slides.append( + CompositeVideoClip(text_clips, size=(W, H)).with_effects( + [FadeIn(0.3), FadeOut(0.3)] + ) + ) + return slides diff --git a/python_pkg/praca_magisterska_video/_q24_rpn_yolo.py b/python_pkg/praca_magisterska_video/_q24_rpn_yolo.py new file mode 100644 index 0000000..f7cf98f --- /dev/null +++ b/python_pkg/praca_magisterska_video/_q24_rpn_yolo.py @@ -0,0 +1,383 @@ +"""RPN anchor boxes and YOLO grid detection.""" + +from __future__ import annotations + +from _q24_common import ( + BG_COLOR, + FONT_B, + FONT_R, + FPS, + STEP_DUR, + H, + W, + _tc, + _text_slide, +) +from moviepy import CompositeVideoClip, VideoClip +from moviepy.video.fx import FadeIn, FadeOut +import numpy as np + + +# ── RPN + Anchor Boxes ─────────────────────────────────────────── +def _rpn_anchors_demo() -> list[CompositeVideoClip]: + """Animate RPN and anchor boxes: Faster R-CNN innovation.""" + slides = [] + + # Slide 1: Anchor boxes concept + def make_anchors_frame(t: float) -> np.ndarray: + frame = np.zeros((H, W, 3), dtype=np.uint8) + frame[:] = BG_COLOR + progress = min(t / (STEP_DUR * 0.7), 1.0) + + # Draw feature map grid point with multiple anchors + cx, cy = 350, 360 # center point on feature map + + # Draw a "feature map" grid background + cell = 60 + for r in range(-3, 4): + for c in range(-3, 4): + x = cx + c * cell - cell // 2 + y = cy + r * cell - cell // 2 + frame[y : y + cell - 1, x : x + cell - 1] = (30, 35, 48) + + # Center point highlighted + frame[cy - 5 : cy + 5, cx - 5 : cx + 5] = (255, 200, 50) + + # Draw anchors around center: 3 sizes x 3 ratios = 9 + anchor_specs = [ + (30, 30, (200, 80, 80)), # small 1:1 + (20, 40, (200, 60, 60)), # small 1:2 + (40, 20, (180, 60, 60)), # small 2:1 + (60, 60, (80, 200, 80)), # medium 1:1 + (40, 80, (60, 180, 60)), # medium 1:2 + (80, 40, (60, 160, 60)), # medium 2:1 + (90, 90, (80, 80, 200)), # large 1:1 + (60, 120, (60, 60, 180)), # large 1:2 + (120, 60, (60, 60, 160)), # large 2:1 + ] + n_anchors = min(int(progress * 9) + 1, 9) + for i in range(n_anchors): + hw, hh, color = anchor_specs[i] + x1 = max(0, cx - hw) + y1 = max(0, cy - hh) + x2 = min(W - 1, cx + hw) + y2 = min(H - 1, cy + hh) + for tt in range(2): + frame[y1 - tt : y2 + tt, x1 - tt : x1 - tt + 2] = color + frame[y1 - tt : y2 + tt, x2 + tt - 2 : x2 + tt] = color + frame[y1 - tt : y1 - tt + 2, x1 - tt : x2 + tt] = color + frame[y2 + tt - 2 : y2 + tt, x1 - tt : x2 + tt] = color + + return frame + + anch_clip = VideoClip(make_anchors_frame, duration=STEP_DUR + 1).with_fps(FPS) + dur = STEP_DUR + 1 + labels = [ + ("Anchor Boxes + RPN (Faster R-CNN)", 26, "#FFE082", FONT_B, (80, 20)), + ( + "KROK 1: Anchory = predefiniowane kształty w każdej pozycji", + 17, + "#A5D6A7", + FONT_R, + (80, 60), + ), + ( + "3 rozmiary x 3 proporcje = 9 anchorów per punkt", + 16, + "#B0BEC5", + FONT_R, + (80, 90), + ), + ("Małe (1:1, 1:2, 2:1)", 14, "#EF9A9A", FONT_R, (750, 170)), + ("Średnie (1:1, 1:2, 2:1)", 14, "#A5D6A7", FONT_R, (750, 210)), + ("Duże (1:1, 1:2, 2:1)", 14, "#64B5F6", FONT_R, (750, 250)), + ("Żółty punkt = pozycja", 14, "#FFE082", FONT_R, (750, 310)), + ("na feature mapie", 14, "#FFE082", FONT_R, (750, 335)), + ("Sieć NIE predykuje bbox od zera!", 16, "white", FONT_R, (80, 530)), + ( + "Predykuje OFFSET od najbliższego anchora: (Δx, Δy, Δw, Δh)", + 16, + "#FFE082", + FONT_R, + (80, 565), + ), + ( + "+ P(obiekt) = 'czy w tym anchorze jest coś?'", + 16, + "#A5D6A7", + FONT_R, + (80, 600), + ), + ( + "Mnemonik: Anchor = KOTWICA — sieć dopasowuje bbox do kotwicy", + 15, + "#78909C", + FONT_R, + (80, 645), + ), + ] + text_clips: list[VideoClip] = [anch_clip] + for text, fs, color, font, pos in labels: + tc = ( + _tc(text=text, font_size=fs, color=color, font=font) + .with_duration(dur) + .with_position(pos) + ) + text_clips.append(tc) + slides.append( + CompositeVideoClip(text_clips, size=(W, H)).with_effects( + [FadeIn(0.3), FadeOut(0.3)] + ) + ) + + # Slide 2: RPN step by step + rpn_lines = [ + ( + "RPN: Region Proposal Network — krok po kroku", + 24, + "#FFE082", + FONT_B, + (80, 30), + ), + ( + "Zastępuje Selective Search SIECIĄ NEURONOWĄ (end-to-end!)", + 17, + "#B0BEC5", + FONT_R, + (80, 85), + ), + ("", 10, "white", FONT_R, (80, 110)), + ( + "1. Backbone (ResNet) przetwarza obraz → feature mapa [40x60x256]", + 16, + "#64B5F6", + FONT_R, + (100, 140), + ), + ( + "2. Filtr 3x3 przesuwa się po feature mapie", + 16, + "#A5D6A7", + FONT_R, + (100, 180), + ), + ( + "3. W KAŻDEJ pozycji (x,y) rozważ k=9 anchorów:", + 16, + "#FFE082", + FONT_R, + (100, 220), + ), + (" → P(obiekt) — 'czy tu jest coś?'", 15, "white", FONT_R, (120, 255)), + (" → (Δx, Δy, Δw, Δh) — poprawka pozycji", 15, "white", FONT_R, (120, 285)), + ( + "4. 40x60 pozycji x 9 anchorów = 21 600 kandydatów!", + 16, + "#EF9A9A", + FONT_R, + (100, 325), + ), + ( + "5. Weź ~300 z najwyższym P(obiekt) → ROI Pool → FC", + 16, + "#A5D6A7", + FONT_R, + (100, 365), + ), + ("", 10, "white", FONT_R, (100, 395)), + ("Porównanie generowania propozycji:", 17, "white", FONT_B, (80, 420)), + ( + " Selective Search: ~2000 regionów, osobny algorytm, ~2 sec", + 15, + "#EF9A9A", + FONT_R, + (100, 460), + ), + ( + " RPN: ~300 regionów, W SIECI, ~10 ms → 200x szybciej!", + 15, + "#A5D6A7", + FONT_R, + (100, 495), + ), + ("", 10, "white", FONT_R, (100, 520)), + ( + "Faster R-CNN = Backbone + RPN + ROI Pool + FC — WSZYSTKO end-to-end", + 17, + "#FFE082", + FONT_R, + (80, 545), + ), + ( + "→ 5 fps (0.2 sec/obraz) vs R-CNN 50 sec = 250x szybciej!", + 17, + "#A5D6A7", + FONT_R, + (80, 585), + ), + ( + "Wciąż two-stage: (1) RPN generuje propozycje, (2) FC klasyfikuje", + 15, + "#78909C", + FONT_R, + (80, 630), + ), + ] + slides.append(_text_slide(rpn_lines, duration=STEP_DUR + 1)) + + return slides + + +# ── YOLO ────────────────────────────────────────────────────────── +def _yolo_demo() -> list[CompositeVideoClip]: + """Animate YOLO grid detection concept.""" + slides = [] + + def make_yolo_frame(t: float) -> np.ndarray: + frame = np.zeros((H, W, 3), dtype=np.uint8) + frame[:] = BG_COLOR + + progress = min(t / (STEP_DUR * 0.7), 1.0) + + # Draw image with grid overlay + img_x, img_y = 100, 140 + img_size = 420 + grid_n = 7 + + # Background "image" + frame[img_y : img_y + img_size, img_x : img_x + img_size] = (50, 55, 70) + + # Objects in the image + frame[img_y + 80 : img_y + 200, img_x + 50 : img_x + 180] = ( + 180, + 60, + 60, + ) # "car" + frame[img_y + 150 : img_y + 350, img_x + 250 : img_x + 330] = ( + 60, + 120, + 180, + ) # "person" + + # Grid lines + cell = img_size // grid_n + for i in range(grid_n + 1): + # Vertical + x = img_x + i * cell + frame[img_y : img_y + img_size, x : x + 1] = (100, 100, 120) + # Horizontal + y = img_y + i * cell + frame[y : y + 1, img_x : img_x + img_size] = (100, 100, 120) + + # Highlight cells containing object centers + car_phase = 0.3 + if progress > car_phase: + # Car center ~ cell (1, 1) + cx, cy = 1, 2 + hx = img_x + cx * cell + hy = img_y + cy * cell + frame[hy : hy + cell, hx : hx + cell] = np.clip( + frame[hy : hy + cell, hx : hx + cell].astype(int) + 40, 0, 255 + ).astype(np.uint8) + + person_phase = 0.5 + if progress > person_phase: + # Person center ~ cell (4, 4) + cx, cy = 4, 4 + hx = img_x + cx * cell + hy = img_y + cy * cell + frame[hy : hy + cell, hx : hx + cell] = np.clip( + frame[hy : hy + cell, hx : hx + cell].astype(int) + 40, 0, 255 + ).astype(np.uint8) + + # Bounding boxes predictions from cells + bbox_phase = 0.6 + if progress > bbox_phase: + # Car bbox + for tt in range(2): + frame[ + img_y + 78 - tt : img_y + 202 + tt, + img_x + 48 - tt : img_x + 48 - tt + 2, + ] = (255, 80, 80) + frame[ + img_y + 78 - tt : img_y + 202 + tt, + img_x + 182 + tt - 2 : img_x + 182 + tt, + ] = (255, 80, 80) + frame[ + img_y + 78 - tt : img_y + 78 - tt + 2, + img_x + 48 - tt : img_x + 182 + tt, + ] = (255, 80, 80) + frame[ + img_y + 202 + tt - 2 : img_y + 202 + tt, + img_x + 48 - tt : img_x + 182 + tt, + ] = (255, 80, 80) + + # Person bbox + for tt in range(2): + frame[ + img_y + 148 - tt : img_y + 352 + tt, + img_x + 248 - tt : img_x + 248 - tt + 2, + ] = (80, 180, 255) + frame[ + img_y + 148 - tt : img_y + 352 + tt, + img_x + 332 + tt - 2 : img_x + 332 + tt, + ] = (80, 180, 255) + frame[ + img_y + 148 - tt : img_y + 148 - tt + 2, + img_x + 248 - tt : img_x + 332 + tt, + ] = (80, 180, 255) + frame[ + img_y + 352 + tt - 2 : img_y + 352 + tt, + img_x + 248 - tt : img_x + 332 + tt, + ] = (80, 180, 255) + + return frame + + yolo_clip = VideoClip(make_yolo_frame, duration=STEP_DUR).with_fps(FPS) + text_clips: list[VideoClip] = [yolo_clip] + labels = [ + ("YOLO — You Only Look Once", 28, "#FFE082", FONT_B, (80, 20)), + ( + "Jednoetapowy detektor: siatka SxS → wszystkie detekcje naraz!", + 18, + "#B0BEC5", + FONT_R, + (80, 65), + ), + ("Siatka 7x7 = 49 komórek", 16, "#64B5F6", FONT_R, (600, 180)), + ("Każda komórka predykuje:", 16, "white", FONT_R, (600, 220)), + (" • B bbox (x, y, w, h, conf)", 14, "#B0BEC5", FONT_R, (600, 255)), + (" • C klas (prawdopodobieństwa)", 14, "#B0BEC5", FONT_R, (600, 285)), + ("Komórka odpowiada za obiekt", 14, "#A5D6A7", FONT_R, (600, 325)), + ("którego ŚRODEK w niej wpada", 14, "#A5D6A7", FONT_R, (600, 350)), + ("45-155 fps! (vs 5 fps Faster R-CNN)", 18, "#EF9A9A", FONT_B, (600, 400)), + ( + "Jedno przejście przez sieć → WSZYSTKIE detekcje naraz → NMS → wynik", + 14, + "#78909C", + FONT_R, + (80, 620), + ), + ( + "Two-stage (R-CNN): propozycje+klasyfikacja " + "| One-stage (YOLO): bez propozycji!", + 14, + "#90CAF9", + FONT_R, + (80, 655), + ), + ] + for text, fs, color, font, pos in labels: + tc = ( + _tc(text=text, font_size=fs, color=color, font=font) + .with_duration(STEP_DUR) + .with_position(pos) + ) + text_clips.append(tc) + + slides.append( + CompositeVideoClip(text_clips, size=(W, H)).with_effects( + [FadeIn(0.3), FadeOut(0.3)] + ) + ) + return slides diff --git a/python_pkg/praca_magisterska_video/_q24_yolo_arch_detr.py b/python_pkg/praca_magisterska_video/_q24_yolo_arch_detr.py new file mode 100644 index 0000000..193bd2b --- /dev/null +++ b/python_pkg/praca_magisterska_video/_q24_yolo_arch_detr.py @@ -0,0 +1,459 @@ +"""YOLO architecture detail and DETR transformer detection.""" + +from __future__ import annotations + +from _q24_common import ( + BG_COLOR, + FONT_B, + FONT_R, + FPS, + STEP_DUR, + H, + W, + _tc, + _text_slide, +) +from moviepy import CompositeVideoClip, VideoClip +from moviepy.video.fx import FadeIn, FadeOut +import numpy as np + + +# ── YOLO Architecture Detail ────────────────────────────────────── +def _yolo_architecture() -> list[CompositeVideoClip]: + """Show YOLO architecture: backbone → head, output tensor.""" + slides = [] + + # Slide 1: YOLO architecture breakdown + def make_yolo_arch(t: float) -> np.ndarray: + frame = np.zeros((H, W, 3), dtype=np.uint8) + frame[:] = BG_COLOR + progress = min(t / (STEP_DUR * 0.7), 1.0) + + # Pipeline: Image → Backbone → Neck → Head → SxSx(B*5+C) tensor + blocks = [ + ((60, 280), (100, 80), (50, 70, 90), "Obraz"), + ((200, 280), (100, 80), (70, 130, 200), "Backbone"), + ((340, 280), (100, 80), (200, 160, 80), "Neck"), + ((480, 280), (100, 80), (200, 100, 60), "Head"), + ((620, 280), (160, 80), (80, 200, 120), "SxSx(B*5+C)"), + ] + n_blocks = min(int(progress * 5) + 1, 5) + for i, ((bx, by), (bw, bh), color, _lbl) in enumerate(blocks): + if i < n_blocks: + frame[by : by + bh, bx : bx + bw] = color + frame[by : by + 2, bx : bx + bw] = tuple( + min(c + 50, 255) for c in color + ) + frame[by + bh - 2 : by + bh, bx : bx + bw] = tuple( + min(c + 50, 255) for c in color + ) + arrow_limit = 4 + if i < arrow_limit: + ax = bx + bw + 5 + ay = by + bh // 2 + frame[ay - 1 : ay + 2, ax : ax + 25] = (150, 150, 170) + + # Output tensor breakdown (right side) + tensor_phase = 0.6 + if progress > tensor_phase: + # Show SxS grid + gx, gy = 850, 180 + gs = 120 + gn = 4 # simplified from 7 + gc = gs // gn + for r in range(gn): + for c in range(gn): + x = gx + c * gc + y = gy + r * gc + frame[y : y + gc - 1, x : x + gc - 1] = (40, 50, 65) + # Highlight one cell + frame[gy + gc : gy + 2 * gc - 1, gx + gc : gx + 2 * gc - 1] = ( + 80, + 200, + 120, + ) + + return frame + + arch_clip = VideoClip(make_yolo_arch, duration=STEP_DUR + 1).with_fps(FPS) + dur = STEP_DUR + 1 + labels = [ + ("YOLO: Architektura — krok po kroku", 26, "#FFE082", FONT_B, (80, 20)), + ( + "One-stage: JEDEN forward pass → WSZYSTKIE detekcje naraz", + 17, + "#B0BEC5", + FONT_R, + (80, 60), + ), + ("Obraz", 13, "white", FONT_R, (85, 295)), + ("Backbone", 13, "white", FONT_R, (215, 295)), + ("(ResNet/", 11, "#78909C", FONT_R, (210, 370)), + ("Darknet)", 11, "#78909C", FONT_R, (210, 390)), + ("Neck", 13, "white", FONT_R, (365, 295)), + ("(FPN/", 11, "#78909C", FONT_R, (360, 370)), + ("PANet)", 11, "#78909C", FONT_R, (360, 390)), + ("Head", 13, "white", FONT_R, (505, 295)), + ("(conv)", 11, "#78909C", FONT_R, (500, 370)), + ("Tensor wyjścia", 13, "#A5D6A7", FONT_R, (640, 295)), + ("Każda komórka SxS predykuje:", 15, "#FFE082", FONT_R, (830, 320)), + (" B bbox x (x,y,w,h,conf)", 13, "#B0BEC5", FONT_R, (830, 350)), + (" + C klas (prob.)", 13, "#B0BEC5", FONT_R, (830, 375)), + ("= SxSx(Bx5+C) tensor", 13, "#A5D6A7", FONT_R, (830, 400)), + ("Np. 7x7x(2x5+20) = 7x7x30", 13, "#78909C", FONT_R, (830, 430)), + ( + "Two-stage (R-CNN): (1) propozycje → (2) klasyfikacja = 2 przejścia", + 15, + "#EF9A9A", + FONT_R, + (80, 470), + ), + ( + "One-stage (YOLO): siatka → predykcja all-in-one = 1 przejście!", + 15, + "#A5D6A7", + FONT_R, + (80, 505), + ), + ( + "Ewolucja YOLO: v1(2016)→v3→v5→v8(2023, anchor-free, SOTA)", + 16, + "#FFE082", + FONT_R, + (80, 555), + ), + ( + "SSD (2016): multi-scale feature maps → lepsza detekcja małych obiektów", + 15, + "#64B5F6", + FONT_R, + (80, 595), + ), + ( + "FPN: łączy wczesne warstwy (małe obiekty) + późne (duże obiekty)", + 15, + "#78909C", + FONT_R, + (80, 630), + ), + ] + text_clips: list[VideoClip] = [arch_clip] + for text, fs, color, font, pos in labels: + tc = ( + _tc(text=text, font_size=fs, color=color, font=font) + .with_duration(dur) + .with_position(pos) + ) + text_clips.append(tc) + slides.append( + CompositeVideoClip(text_clips, size=(W, H)).with_effects( + [FadeIn(0.3), FadeOut(0.3)] + ) + ) + + return slides + + +# ── DETR ────────────────────────────────────────────────────────── +def _detr_demo() -> list[CompositeVideoClip]: + """Animate DETR: transformer detection, object queries, no NMS.""" + slides = [] + + # Slide 1: DETR pipeline + def make_detr_frame(t: float) -> np.ndarray: + frame = np.zeros((H, W, 3), dtype=np.uint8) + frame[:] = BG_COLOR + progress = min(t / (STEP_DUR * 0.7), 1.0) + + # DETR pipeline: Image → Backbone → Encoder → Decoder → N predictions + blocks = [ + ((50, 260), (80, 60), (50, 70, 90)), + ((170, 260), (90, 60), (70, 130, 200)), + ((300, 260), (110, 60), (200, 120, 60)), + ((450, 260), (110, 60), (200, 80, 160)), + ((600, 260), (120, 60), (80, 200, 120)), + ] + n_blocks = min(int(progress * 5) + 1, 5) + for i, ((bx, by), (bw, bh), color) in enumerate(blocks): + if i < n_blocks: + frame[by : by + bh, bx : bx + bw] = color + frame[by : by + 2, bx : bx + bw] = tuple( + min(c + 50, 255) for c in color + ) + frame[by + bh - 2 : by + bh, bx : bx + bw] = tuple( + min(c + 50, 255) for c in color + ) + arrow_limit = 4 + if i < arrow_limit: + ax = bx + bw + 5 + ay = by + bh // 2 + frame[ay - 1 : ay + 2, ax : ax + 25] = (150, 150, 170) + + # Object queries illustration (right side) + query_phase = 0.5 + if progress > query_phase: + qx, qy = 800, 140 + for i in range(6): + y = qy + i * 50 + w = 130 + active_limit = 3 + active = i < active_limit + color = (80, 180, 120) if active else (60, 50, 50) + frame[y : y + 35, qx : qx + w] = color + frame[y : y + 1, qx : qx + w] = tuple(min(c + 40, 255) for c in color) + + # Arrow from decoder to queries + frame[285:288, 723:798] = (150, 150, 170) + + return frame + + detr_clip = VideoClip(make_detr_frame, duration=STEP_DUR + 1).with_fps(FPS) + dur = STEP_DUR + 1 + labels = [ + ("DETR: DEtection TRansformer (2020)", 26, "#FFE082", FONT_B, (80, 20)), + ( + "Radykalnie prostszy pipeline: BEZ anchorów, BEZ NMS!", + 17, + "#B0BEC5", + FONT_R, + (80, 60), + ), + ("Obraz", 12, "white", FONT_R, (65, 275)), + ("Backbone", 12, "white", FONT_R, (185, 275)), + ("Transformer", 12, "white", FONT_R, (310, 275)), + ("Encoder", 12, "white", FONT_R, (325, 295)), + ("Transformer", 12, "white", FONT_R, (460, 275)), + ("Decoder", 12, "white", FONT_R, (478, 295)), + ("N predykcji", 12, "white", FONT_R, (615, 275)), + ("Object Queries:", 14, "#FFE082", FONT_B, (800, 115)), + ("samochód 95%", 11, "white", FONT_R, (810, 148)), + ("pies 88%", 11, "white", FONT_R, (810, 198)), + ("rower 72%", 11, "white", FONT_R, (810, 248)), + ("brak", 11, "#78909C", FONT_R, (810, 298)), + ("brak", 11, "#78909C", FONT_R, (810, 348)), + ("brak", 11, "#78909C", FONT_R, (810, 398)), + ("100 wyuczonych queries", 13, "#FFE082", FONT_R, (800, 440)), + ("→ każdy 'szuka' obiektu", 13, "#FFE082", FONT_R, (800, 465)), + ] + text_clips: list[VideoClip] = [detr_clip] + for text, fs, color, font, pos in labels: + tc = ( + _tc(text=text, font_size=fs, color=color, font=font) + .with_duration(dur) + .with_position(pos) + ) + text_clips.append(tc) + slides.append( + CompositeVideoClip(text_clips, size=(W, H)).with_effects( + [FadeIn(0.3), FadeOut(0.3)] + ) + ) + + # Slide 2: Why no NMS + Hungarian matching + detr_details = [ + ("DETR: Dlaczego bez NMS? — krok po kroku", 24, "#FFE082", FONT_B, (80, 30)), + ( + "Problem NMS: duplikaty detekcji → ręcznie usuwaj post-hoc", + 16, + "#EF9A9A", + FONT_R, + (80, 90), + ), + ( + "DETR rozwiązanie: Hungarian matching (dopasowanie węgierskie)", + 17, + "#A5D6A7", + FONT_R, + (80, 130), + ), + ("", 10, "white", FONT_R, (80, 155)), + ("Jak to działa podczas TRENINGU:", 17, "white", FONT_B, (80, 180)), + ( + " 1. Sieć daje N=100 predykcji (queries)", + 15, + "#64B5F6", + FONT_R, + (100, 220), + ), + ( + " 2. Na obrazie jest np. 5 obiektów (ground truth)", + 15, + "#64B5F6", + FONT_R, + (100, 255), + ), + ( + " 3. Hungarian matching: optymalne dopasowanie 1:1", + 15, + "#FFE082", + FONT_R, + (100, 290), + ), + ( + " → query_1 ↔ gt_samochód (najlepsze dopasowanie)", + 14, + "#A5D6A7", + FONT_R, + (120, 325), + ), + (" → query_7 ↔ gt_pies", 14, "#A5D6A7", FONT_R, (120, 355)), + (" → query_3 ↔ gt_rower", 14, "#A5D6A7", FONT_R, (120, 385)), + ( + " → pozostałe 97 queries ↔ klasa 'brak obiektu'", + 14, + "#78909C", + FONT_R, + (120, 415), + ), + ( + " 4. Każdy obiekt ma DOKŁADNIE 1 predykcję → BRAK duplikatów!", + 15, + "#A5D6A7", + FONT_R, + (100, 455), + ), + ("", 10, "white", FONT_R, (100, 475)), + ( + "Self-attention w encoderze: cechy obrazu 'rozmawiają' ze sobą", + 15, + "#64B5F6", + FONT_R, + (80, 500), + ), + ( + "Cross-attention w decoderze: queries 'pytają' cechy obrazu", + 15, + "#CE93D8", + FONT_R, + (80, 535), + ), + ( + "→ query 'rozumie' który fragment obrazu to 'jego' obiekt", + 15, + "#FFE082", + FONT_R, + (80, 570), + ), + ( + "DETR = Detekcja Eliminująca Trikowe Redundancje (NMS, anchory)", + 16, + "#FFE082", + FONT_R, + (80, 620), + ), + ( + "Wada: wolniejszy trening (O(n²) attention) | Zaleta: prostszy pipeline!", + 15, + "#78909C", + FONT_R, + (80, 660), + ), + ] + slides.append(_text_slide(detr_details, duration=STEP_DUR + 1)) + + # Slide 3: Two-stage vs One-stage vs Transformer summary + summary_lines = [ + ( + "Podsumowanie: Two-stage vs One-stage vs Transformer", + 22, + "#FFE082", + FONT_B, + (80, 30), + ), + ("", 10, "white", FONT_R, (80, 55)), + ("TWO-STAGE (R-CNN family):", 18, "#EF9A9A", FONT_B, (80, 90)), + ( + " (1) Generuj propozycje → (2) Klasyfikuj per region", + 15, + "white", + FONT_R, + (100, 125), + ), + ( + " + Wysoka precyzja | - Wolniejsze (2 przejścia)", + 15, + "#78909C", + FONT_R, + (100, 155), + ), + ( + " R-CNN → Fast R-CNN → Faster R-CNN (0.2s)", + 15, + "#B0BEC5", + FONT_R, + (100, 185), + ), + ("", 10, "white", FONT_R, (80, 210)), + ("ONE-STAGE (YOLO, SSD):", 18, "#A5D6A7", FONT_B, (80, 240)), + ( + " Siatka → predykcja all-in-one (1 przejście)", + 15, + "white", + FONT_R, + (100, 275), + ), + ( + " + Bardzo szybkie (45-155 fps) | - Historycznie mniej precyzyjne", + 15, + "#78909C", + FONT_R, + (100, 305), + ), + ( + " YOLOv8 (2023): anchor-free, dorównuje two-stage!", + 15, + "#B0BEC5", + FONT_R, + (100, 335), + ), + ("", 10, "white", FONT_R, (80, 360)), + ("TRANSFORMER (DETR):", 18, "#CE93D8", FONT_B, (80, 390)), + ( + " Object queries + self-attention (globalny kontekst)", + 15, + "white", + FONT_R, + (100, 425), + ), + ( + " + Brak NMS/anchorów | - Wolniejszy trening (O(n²))", + 15, + "#78909C", + FONT_R, + (100, 455), + ), + ( + " Hungarian matching → 1:1 obiekt↔predykcja → brak duplikatów", + 15, + "#B0BEC5", + FONT_R, + (100, 485), + ), + ("", 10, "white", FONT_R, (80, 510)), + ( + "Trend: coraz prostsze pipeline, mniej ręcznych komponentów", + 17, + "white", + FONT_R, + (80, 540), + ), + ( + " R-CNN (SS+CNN+SVM+NMS) → YOLO " + "(backbone+head+NMS) → DETR (backbone+transformer)", + 14, + "#90CAF9", + FONT_R, + (80, 580), + ), + ( + "Metryki: mAP@0.5 (standard), mAP@0.5:0.95 (surowsza), " + "IoU do dopasowania", + 15, + "#78909C", + FONT_R, + (80, 630), + ), + ] + slides.append(_text_slide(summary_lines, duration=STEP_DUR + 1)) + + return slides diff --git a/python_pkg/praca_magisterska_video/generate_images/_pubsub_common.py b/python_pkg/praca_magisterska_video/generate_images/_pubsub_common.py new file mode 100644 index 0000000..caf4a7a --- /dev/null +++ b/python_pkg/praca_magisterska_video/generate_images/_pubsub_common.py @@ -0,0 +1,235 @@ +"""Common drawing primitives and constants for Pub/Sub diagrams.""" + +from __future__ import annotations + +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING + +import matplotlib as mpl + +mpl.use("Agg") + +import matplotlib.patches as mpatches +from matplotlib.patches import FancyBboxPatch +import matplotlib.pyplot as plt + +if TYPE_CHECKING: + from matplotlib.axes import Axes + +DPI = 300 +BG = "white" +LN = "black" +FS = 9 +FS_TITLE = 13 +FIG_W = 8.27 # A4 width in inches +OUTPUT_DIR = str(Path(__file__).resolve().parent / "img") +Path(OUTPUT_DIR).mkdir(parents=True, exist_ok=True) + +GRAY1 = "#E8E8E8" +GRAY2 = "#D0D0D0" +GRAY3 = "#B8B8B8" +GRAY4 = "#F5F5F5" +GRAY5 = "#C0C0C0" + + +@dataclass(frozen=True) +class BoxStyle: + """Optional styling for boxes.""" + + fill: str = "white" + lw: float = 1.2 + fontsize: float = FS + fontweight: str = "normal" + ha: str = "center" + va: str = "center" + rounded: bool = True + + +@dataclass(frozen=True) +class ArrowCfg: + """Config for arrows.""" + + lw: float = 1.2 + style: str = "->" + color: str = LN + label: str = "" + label_offset: float = 0.15 + label_fs: float = 8 + + +@dataclass(frozen=True) +class DashedCfg: + """Config for dashed arrows.""" + + lw: float = 1.0 + color: str = LN + label: str = "" + label_offset: float = 0.15 + label_fs: float = 8 + + +def draw_box( + ax: Axes, + pos: tuple[float, float], + size: tuple[float, float], + text: str, + style: BoxStyle | None = None, +) -> None: + """Draw box.""" + s = style or BoxStyle() + x, y = pos + w, h = size + if s.rounded: + rect = FancyBboxPatch( + (x, y), + w, + h, + boxstyle="round,pad=0.05", + lw=s.lw, + edgecolor=LN, + facecolor=s.fill, + ) + else: + rect = mpatches.Rectangle( + (x, y), + w, + h, + lw=s.lw, + edgecolor=LN, + facecolor=s.fill, + ) + ax.add_patch(rect) + ax.text( + x + w / 2, + y + h / 2, + text, + ha=s.ha, + va=s.va, + fontsize=s.fontsize, + fontweight=s.fontweight, + wrap=True, + ) + + +def draw_arrow( + ax: Axes, + start: tuple[float, float], + end: tuple[float, float], + cfg: ArrowCfg | None = None, +) -> None: + """Draw arrow.""" + c = cfg or ArrowCfg() + ax.annotate( + "", + xy=end, + xytext=start, + arrowprops={ + "arrowstyle": c.style, + "color": c.color, + "lw": c.lw, + }, + ) + if c.label: + mx = (start[0] + end[0]) / 2 + my = (start[1] + end[1]) / 2 + c.label_offset + ax.text( + mx, + my, + c.label, + ha="center", + va="bottom", + fontsize=c.label_fs, + color=c.color, + ) + + +def draw_dashed_arrow( + ax: Axes, + start: tuple[float, float], + end: tuple[float, float], + cfg: DashedCfg | None = None, +) -> None: + """Draw dashed arrow.""" + c = cfg or DashedCfg() + ax.annotate( + "", + xy=end, + xytext=start, + arrowprops={ + "arrowstyle": "->", + "color": c.color, + "lw": c.lw, + "linestyle": "dashed", + }, + ) + if c.label: + mx = (start[0] + end[0]) / 2 + my = (start[1] + end[1]) / 2 + c.label_offset + ax.text( + mx, + my, + c.label, + ha="center", + va="bottom", + fontsize=c.label_fs, + color=c.color, + ) + + +def draw_cross( + ax: Axes, + pos: tuple[float, float], + size: float = 0.15, + lw: float = 2.5, + color: str = "black", +) -> None: + """Draw cross.""" + x, y = pos + ax.plot( + [x - size, x + size], + [y - size, y + size], + color=color, + lw=lw, + ) + ax.plot( + [x - size, x + size], + [y + size, y - size], + color=color, + lw=lw, + ) + + +def draw_check( + ax: Axes, + pos: tuple[float, float], + size: float = 0.15, + lw: float = 2.5, + color: str = "black", +) -> None: + """Draw check.""" + x, y = pos + ax.plot( + [x - size, x - size * 0.2], + [y, y - size * 0.7], + color=color, + lw=lw, + ) + ax.plot( + [x - size * 0.2, x + size], + [y - size * 0.7, y + size * 0.5], + color=color, + lw=lw, + ) + + +def save(fig: plt.Figure, name: str) -> None: + """Save.""" + plt.tight_layout() + fig.savefig( + str(Path(OUTPUT_DIR) / name), + dpi=DPI, + bbox_inches="tight", + facecolor=BG, + ) + plt.close(fig) diff --git a/python_pkg/praca_magisterska_video/generate_images/_pubsub_qos.py b/python_pkg/praca_magisterska_video/generate_images/_pubsub_qos.py new file mode 100644 index 0000000..a68178f --- /dev/null +++ b/python_pkg/praca_magisterska_video/generate_images/_pubsub_qos.py @@ -0,0 +1,430 @@ +"""QoS delivery guarantee diagrams: at-most-once, at-least-once, exactly-once.""" + +from __future__ import annotations + +from _pubsub_common import ( + FIG_W, + FS_TITLE, + GRAY1, + GRAY2, + GRAY3, + GRAY4, + LN, + ArrowCfg, + BoxStyle, + draw_arrow, + draw_box, + draw_check, + draw_cross, + draw_dashed_arrow, + save, +) +import matplotlib.pyplot as plt + + +# ============================================================ +# 5. At-most-once (QoS 0) +# ============================================================ +def draw_qos_at_most_once() -> None: + """Draw qos at most once.""" + fig, ax = plt.subplots(1, 1, figsize=(FIG_W, 4.5)) + ax.set_xlim(0, 12) + ax.set_ylim(0, 6) + ax.set_aspect("equal") + ax.axis("off") + ax.set_title( + "QoS: At-most-once" + " \u2014 \u201ewy\u015blij i zapomnij\u201d" + " (0 lub 1 dostarczenie)", + fontsize=FS_TITLE, + fontweight="bold", + pad=12, + ) + + px, bx, sx = 1.0, 4.8, 8.5 + pw, bw, sw = 2.0, 2.2, 2.0 + bh = 0.8 + bold10_g1 = BoxStyle(fill=GRAY1, fontsize=10, fontweight="bold") + bold10_g2 = BoxStyle(fill=GRAY2, fontsize=10, fontweight="bold") + draw_box(ax, (px, 5.0), (pw, bh), "Publisher", bold10_g1) + draw_box(ax, (bx, 5.0), (bw, bh), "Broker", bold10_g2) + draw_box( + ax, + (sx, 5.0), + (sw, bh), + "Subscriber", + bold10_g1, + ) + + for xc in [px + pw / 2, bx + bw / 2, sx + sw / 2]: + ax.plot( + [xc, xc], + [5.0, 1.2], + color=GRAY3, + lw=1, + linestyle=":", + ) + + # Scenario A: success + y = 4.3 + ax.text( + 0.2, + y + 0.15, + "Scenariusz A:", + fontsize=8.5, + fontweight="bold", + ) + msg9 = ArrowCfg(label="MSG", label_fs=9) + draw_arrow(ax, (px + pw / 2, y), (bx + bw / 2, y), msg9) + draw_arrow( + ax, + (bx + bw / 2, y - 0.6), + (sx + sw / 2, y - 0.6), + msg9, + ) + draw_check(ax, (sx + sw / 2 + 0.4, y - 0.6), size=0.18) + ax.text( + sx + sw / 2 + 0.7, + y - 0.6, + "OK", + fontsize=9, + fontweight="bold", + ) + + # Scenario B: lost + y = 2.6 + ax.text( + 0.2, + y + 0.15, + "Scenariusz B:", + fontsize=8.5, + fontweight="bold", + ) + draw_arrow(ax, (px + pw / 2, y), (bx + bw / 2, y), msg9) + draw_dashed_arrow(ax, (bx + bw / 2, y - 0.6), (7.5, y - 0.6)) + draw_cross(ax, (7.8, y - 0.6), size=0.2) + ax.text( + 8.2, + y - 0.55, + "UTRACONA", + fontsize=9, + fontweight="bold", + ) + ax.text( + 8.2, + y - 1.0, + "(brak retransmisji)", + fontsize=8, + style="italic", + ) + + ax.text( + 6.0, + 0.5, + "Brak ACK, brak retransmisji." + " Najszybszy. Use case:" + " logi, metryki, telemetria.", + ha="center", + va="center", + fontsize=9, + bbox={ + "boxstyle": "round,pad=0.4", + "facecolor": GRAY4, + "edgecolor": GRAY3, + }, + ) + + save(fig, "pubsub_qos_at_most_once.png") + + +# ============================================================ +# 6. At-least-once (QoS 1) +# ============================================================ +def draw_qos_at_least_once() -> None: + """Draw qos at least once.""" + fig, ax = plt.subplots(1, 1, figsize=(FIG_W, 5.0)) + ax.set_xlim(0, 12) + ax.set_ylim(0, 6.5) + ax.set_aspect("equal") + ax.axis("off") + ax.set_title( + "QoS: At-least-once" + " \u2014 \u201epowtarzaj a\u017c potwierdz\u0105\u201d" + " (\u22651 dostarczenie)", + fontsize=FS_TITLE, + fontweight="bold", + pad=12, + ) + + bx, bw = 3.5, 2.2 + sx, sw = 8.0, 2.2 + bh = 0.8 + draw_box( + ax, + (bx, 5.5), + (bw, bh), + "Broker", + BoxStyle(fill=GRAY2, fontsize=10, fontweight="bold"), + ) + draw_box( + ax, + (sx, 5.5), + (sw, bh), + "Subscriber", + BoxStyle(fill=GRAY1, fontsize=10, fontweight="bold"), + ) + + for xc in [bx + bw / 2, sx + sw / 2]: + ax.plot( + [xc, xc], + [5.5, 0.8], + color=GRAY3, + lw=1, + linestyle=":", + ) + + # Step 1: send MSG + y1 = 4.8 + draw_arrow( + ax, + (bx + bw / 2, y1), + (sx + sw / 2, y1), + ArrowCfg(label="MSG #1", label_fs=9), + ) + draw_check(ax, (sx + sw + 0.2, y1), size=0.15) + ax.text(sx + sw + 0.5, y1, "odebrano", fontsize=8) + + # Step 2: ACK lost + y2 = 3.9 + draw_dashed_arrow( + ax, + (sx + sw / 2, y2), + (bx + bw + 1.2, y2), + ) + ax.text( + (bx + bw / 2 + sx + sw / 2) / 2, + y2 + 0.18, + "ACK", + fontsize=9, + ) + draw_cross(ax, (bx + bw + 0.8, y2), size=0.18) + ax.text( + bx + 0.3, + y2 - 0.35, + "ACK utracony!", + fontsize=8.5, + style="italic", + ) + + # Step 3: timeout -> retry + y3 = 2.9 + ax.text( + bx + bw / 2, + y3 + 0.45, + "timeout...", + fontsize=8.5, + style="italic", + ha="center", + ) + draw_arrow( + ax, + (bx + bw / 2, y3), + (sx + sw / 2, y3), + ArrowCfg(label="MSG #1 (retry)", label_fs=9), + ) + draw_check(ax, (sx + sw + 0.2, y3), size=0.15) + ax.text( + sx + sw + 0.5, + y3, + "odebrano\n(ponownie!)", + fontsize=8, + ) + + # Step 4: ACK ok + y4 = 2.0 + draw_arrow( + ax, + (sx + sw / 2, y4), + (bx + bw / 2, y4), + ArrowCfg(label="ACK", label_fs=9), + ) + draw_check(ax, (bx + bw / 2 - 0.5, y4), size=0.18) + + # Duplicate bracket + ax.annotate( + "", + xy=(sx + sw + 1.3, y1), + xytext=(sx + sw + 1.3, y3), + arrowprops={ + "arrowstyle": "<->", + "color": "black", + "lw": 1.2, + }, + ) + ax.text( + sx + sw + 1.6, + (y1 + y3) / 2, + "DUPLIKAT!\nSubscriber\notrzyma\u0142 2x", + fontsize=9, + ha="left", + va="center", + fontweight="bold", + bbox={ + "boxstyle": "round,pad=0.25", + "facecolor": GRAY4, + "edgecolor": GRAY3, + }, + ) + + ax.text( + 6.0, + 0.5, + "Broker czeka na ACK, retransmituje" + " po timeout. Mog\u0105 by\u0107 duplikaty!\n" + "Use case: zam\u00f3wienia, p\u0142atno\u015bci" + " (subscriber musi by\u0107 idempotentny).", + ha="center", + va="center", + fontsize=9, + bbox={ + "boxstyle": "round,pad=0.4", + "facecolor": GRAY4, + "edgecolor": GRAY3, + }, + ) + + save(fig, "pubsub_qos_at_least_once.png") + + +# ============================================================ +# 7. Exactly-once (QoS 2) +# ============================================================ +def draw_qos_exactly_once() -> None: + """Draw qos exactly once.""" + fig, ax = plt.subplots(1, 1, figsize=(FIG_W, 5.5)) + ax.set_xlim(0, 12) + ax.set_ylim(0, 7) + ax.set_aspect("equal") + ax.axis("off") + ax.set_title( + "QoS: Exactly-once \u2014 4-krokowy" + " handshake (dok\u0142adnie 1 dostarczenie)", + fontsize=FS_TITLE, + fontweight="bold", + pad=12, + ) + + bx, bw = 2.5, 2.2 + sx, sw = 7.5, 2.2 + bh = 0.8 + draw_box( + ax, + (bx, 6.0), + (bw, bh), + "Broker", + BoxStyle(fill=GRAY2, fontsize=10, fontweight="bold"), + ) + draw_box( + ax, + (sx, 6.0), + (sw, bh), + "Subscriber", + BoxStyle(fill=GRAY1, fontsize=10, fontweight="bold"), + ) + + for xc in [bx + bw / 2, sx + sw / 2]: + ax.plot( + [xc, xc], + [6.0, 1.0], + color=GRAY3, + lw=1, + linestyle=":", + ) + + steps = [ + ( + 5.2, + "right", + "PUBLISH (msg_id=42)", + "Broker wysy\u0142a wiadomo\u015b\u0107", + ), + ( + 4.2, + "left", + "PUBREC (otrzyma\u0142em id=42)", + "Sub potwierdza odbi\u00f3r," " zapisuje id", + ), + ( + 3.2, + "right", + "PUBREL (mo\u017cesz przetworzy\u0107)", + "Broker zwalnia wiadomo\u015b\u0107", + ), + ( + 2.2, + "left", + "PUBCOMP (zako\u0144czone)", + "Sub potwierdza przetworzenie", + ), + ] + + for i, (y, direction, label, desc) in enumerate(steps): + ax.text( + bx + bw / 2 - 0.7, + y, + f"{i + 1}", + fontsize=9, + fontweight="bold", + ha="center", + va="center", + bbox={ + "boxstyle": "circle,pad=0.18", + "facecolor": GRAY3, + "edgecolor": LN, + }, + ) + + if direction == "right": + draw_arrow( + ax, + (bx + bw / 2, y), + (sx + sw / 2, y), + ArrowCfg(label=label, label_fs=9), + ) + else: + draw_arrow( + ax, + (sx + sw / 2, y), + (bx + bw / 2, y), + ArrowCfg(label=label, label_fs=9), + ) + + ax.text( + sx + sw + 0.3, + y, + desc, + fontsize=8, + ha="left", + va="center", + style="italic", + ) + + ax.text( + 6.0, + 0.6, + "Deduplikacja po msg_id." + " Sub nie przetwarza przed PUBREL.\n" + "Najkosztowniejszy (4 pakiety)." + " Use case: transakcje finansowe," + " krytyczne zdarzenia.", + ha="center", + va="center", + fontsize=9, + bbox={ + "boxstyle": "round,pad=0.4", + "facecolor": GRAY4, + "edgecolor": GRAY3, + }, + ) + + save(fig, "pubsub_qos_exactly_once.png") diff --git a/python_pkg/praca_magisterska_video/generate_images/_pubsub_topic_content.py b/python_pkg/praca_magisterska_video/generate_images/_pubsub_topic_content.py new file mode 100644 index 0000000..07d5ab6 --- /dev/null +++ b/python_pkg/praca_magisterska_video/generate_images/_pubsub_topic_content.py @@ -0,0 +1,239 @@ +"""Subscription-type diagrams: topic-based and content-based.""" + +from __future__ import annotations + +from _pubsub_common import ( + FIG_W, + FS_TITLE, + GRAY1, + GRAY2, + GRAY3, + GRAY4, + ArrowCfg, + BoxStyle, + DashedCfg, + draw_arrow, + draw_box, + draw_dashed_arrow, + save, +) +import matplotlib.pyplot as plt + + +# ============================================================ +# 1. Topic-based subscription +# ============================================================ +def draw_sub_topic() -> None: + """Draw sub topic.""" + fig, ax = plt.subplots(1, 1, figsize=(FIG_W, 4.0)) + ax.set_xlim(0, 12) + ax.set_ylim(0, 5.5) + ax.set_aspect("equal") + ax.axis("off") + ax.set_title( + "Subskrypcja topic-based" " \u2014 routing po nazwie tematu", + fontsize=FS_TITLE, + fontweight="bold", + pad=12, + ) + + bold10 = BoxStyle(fill=GRAY1, fontsize=10, fontweight="bold") + fs85 = BoxStyle(fill=GRAY1, fontsize=8.5) + + draw_box(ax, (0.2, 3.2), (2.4, 1.1), "Publisher", bold10) + draw_box( + ax, + (0.3, 1.8), + (2.2, 0.8), + 'topic: "orders"', + BoxStyle(fill=GRAY4, fontsize=8), + ) + draw_box( + ax, + (0.3, 0.7), + (2.2, 0.8), + 'topic: "payments"', + BoxStyle(fill=GRAY4, fontsize=8), + ) + + draw_box( + ax, + (4.2, 1.5), + (2.8, 2.2), + "BROKER\n\ntopic routing", + BoxStyle(fill=GRAY2, fontsize=10, fontweight="bold"), + ) + + draw_box( + ax, + (8.5, 3.8), + (3.0, 1.0), + 'Subscriber A\nsubskrybuje: "orders"', + fs85, + ) + draw_box( + ax, + (8.5, 2.2), + (3.0, 1.0), + 'Subscriber B\nsubskrybuje: "payments"', + fs85, + ) + draw_box( + ax, + (8.5, 0.6), + (3.0, 1.0), + 'Subscriber C\nsubskrybuje: "orders"', + fs85, + ) + + fs8 = ArrowCfg(label_fs=8) + draw_arrow(ax, (2.6, 2.2), (4.2, 2.8), fs8) + draw_arrow(ax, (2.6, 1.1), (4.2, 2.2), fs8) + + draw_arrow( + ax, + (7.0, 3.4), + (8.5, 4.2), + ArrowCfg(label='"orders"', label_fs=8), + ) + draw_arrow( + ax, + (7.0, 2.6), + (8.5, 2.7), + ArrowCfg(label='"payments"', label_fs=8), + ) + draw_arrow( + ax, + (7.0, 2.2), + (8.5, 1.2), + ArrowCfg(label='"orders"', label_fs=8), + ) + + ax.text( + 6.0, + 0.1, + "Subscriber deklaruje nazw\u0119 tematu." + " Broker kieruje wiadomo\u015bci\n" + "do WSZYSTKICH subscriber\u00f3w" + " danego tematu. Najprostszy model.", + ha="center", + va="bottom", + fontsize=8.5, + style="italic", + bbox={ + "boxstyle": "round,pad=0.3", + "facecolor": GRAY4, + "edgecolor": GRAY3, + }, + ) + + save(fig, "pubsub_sub_topic.png") + + +# ============================================================ +# 2. Content-based subscription +# ============================================================ +def draw_sub_content() -> None: + """Draw sub content.""" + fig, ax = plt.subplots(1, 1, figsize=(FIG_W, 4.5)) + ax.set_xlim(0, 12) + ax.set_ylim(0, 6) + ax.set_aspect("equal") + ax.axis("off") + ax.set_title( + "Subskrypcja content-based" + " \u2014 filtrowanie po tre\u015bci wiadomo\u015bci", + fontsize=FS_TITLE, + fontweight="bold", + pad=12, + ) + + bold10 = BoxStyle(fill=GRAY1, fontsize=10, fontweight="bold") + draw_box(ax, (0.2, 3.5), (2.4, 1.1), "Publisher", bold10) + draw_box( + ax, + (0.2, 1.8), + (2.4, 1.2), + 'price: 150\ntype: "book"\ncategory: "IT"', + BoxStyle(fill=GRAY4, fontsize=8.5), + ) + + draw_box( + ax, + (4.0, 2.0), + (3.0, 2.5), + "BROKER\n\newaluuje filtry\n" "ka\u017cdego subscribera", + BoxStyle(fill=GRAY2, fontsize=9, fontweight="bold"), + ) + + fs9 = BoxStyle(fill=GRAY1, fontsize=9) + draw_box( + ax, + (8.5, 4.2), + (3.2, 1.0), + "Sub A\nfiltr: price > 100", + fs9, + ) + draw_box( + ax, + (8.5, 2.6), + (3.2, 1.0), + 'Sub B\nfiltr: type = "food"', + fs9, + ) + draw_box( + ax, + (8.5, 1.0), + (3.2, 1.0), + "Sub C\nfiltr: price < 50", + fs9, + ) + + draw_arrow(ax, (2.6, 2.4), (4.0, 3.0)) + draw_arrow( + ax, + (7.0, 4.0), + (8.5, 4.6), + ArrowCfg( + label="150 > 100 \u2713 dostarczono", + label_fs=8, + ), + ) + draw_dashed_arrow( + ax, + (7.0, 3.2), + (8.5, 3.1), + DashedCfg( + label='"book" \u2260 "food"' " \u2717 odrzucono", + label_fs=8, + ), + ) + draw_dashed_arrow( + ax, + (7.0, 2.5), + (8.5, 1.6), + DashedCfg( + label="150 < 50 \u2717 odrzucono", + label_fs=8, + ), + ) + + ax.text( + 6.0, + 0.2, + "Broker analizuje TRE\u015a\u0106 wiadomo\u015bci" + " i ewaluuje predykaty.\n" + "Bardziej elastyczny ni\u017c topic-based," + " ale wolniejszy (koszt ewaluacji).", + ha="center", + va="bottom", + fontsize=8.5, + style="italic", + bbox={ + "boxstyle": "round,pad=0.3", + "facecolor": GRAY4, + "edgecolor": GRAY3, + }, + ) + + save(fig, "pubsub_sub_content.png") diff --git a/python_pkg/praca_magisterska_video/generate_images/_pubsub_type_hierarchical.py b/python_pkg/praca_magisterska_video/generate_images/_pubsub_type_hierarchical.py new file mode 100644 index 0000000..d619451 --- /dev/null +++ b/python_pkg/praca_magisterska_video/generate_images/_pubsub_type_hierarchical.py @@ -0,0 +1,279 @@ +"""Subscription-type diagrams: type-based and hierarchical.""" + +from __future__ import annotations + +from _pubsub_common import ( + FIG_W, + FS_TITLE, + GRAY1, + GRAY2, + GRAY3, + GRAY4, + ArrowCfg, + BoxStyle, + draw_arrow, + draw_box, + save, +) +import matplotlib.pyplot as plt + + +# ============================================================ +# 3. Type-based subscription +# ============================================================ +def draw_sub_type() -> None: + """Draw sub type.""" + fig, ax = plt.subplots(1, 1, figsize=(FIG_W, 5.0)) + ax.set_xlim(0, 12) + ax.set_ylim(0, 6.5) + ax.set_aspect("equal") + ax.axis("off") + ax.set_title( + "Subskrypcja type-based" " \u2014 routing po typie (klasie) obiektu", + fontsize=FS_TITLE, + fontweight="bold", + pad=12, + ) + + bold10 = BoxStyle(fill=GRAY1, fontsize=10, fontweight="bold") + draw_box(ax, (0.2, 4.2), (2.4, 1.1), "Publisher", bold10) + + fs9_g4 = BoxStyle(fill=GRAY4, fontsize=9) + draw_box( + ax, + (0.1, 2.8), + (2.6, 0.9), + "new OrderEvent()", + fs9_g4, + ) + draw_box( + ax, + (0.1, 1.5), + (2.6, 0.9), + "new PaymentEvent()", + fs9_g4, + ) + + draw_box( + ax, + (4.0, 2.3), + (3.0, 2.4), + "BROKER\n\nrouting po\ntypie klasy", + BoxStyle(fill=GRAY2, fontsize=10, fontweight="bold"), + ) + + fs9 = BoxStyle(fill=GRAY1, fontsize=9) + draw_box( + ax, + (8.5, 4.8), + (3.2, 1.0), + "Sub A\n\u2192 OrderEvent", + fs9, + ) + draw_box( + ax, + (8.5, 3.2), + (3.2, 1.0), + "Sub B\n\u2192 PaymentEvent", + fs9, + ) + draw_box( + ax, + (8.5, 1.6), + (3.2, 1.0), + "Sub C\n\u2192 Event (base)", + fs9, + ) + + draw_arrow(ax, (2.7, 3.2), (4.0, 3.8)) + draw_arrow(ax, (2.7, 2.0), (4.0, 3.0)) + draw_arrow( + ax, + (7.0, 4.3), + (8.5, 5.2), + ArrowCfg(label="OrderEvent", label_fs=8), + ) + draw_arrow( + ax, + (7.0, 3.5), + (8.5, 3.7), + ArrowCfg(label="PaymentEvent", label_fs=8), + ) + draw_arrow( + ax, + (7.0, 3.0), + (8.5, 2.2), + ArrowCfg(label="oba (dziedziczenie!)", label_fs=8), + ) + + hx, hy = 0.5, 0.0 + draw_box( + ax, + (hx + 2.0, hy + 0.2), + (1.8, 0.6), + "Event", + BoxStyle(fill=GRAY3, fontsize=8, fontweight="bold"), + ) + draw_box( + ax, + (hx, hy + 0.2), + (1.8, 0.6), + "OrderEvent", + BoxStyle(fill=GRAY4, fontsize=7.5), + ) + draw_box( + ax, + (hx + 4.0, hy + 0.2), + (2.0, 0.6), + "PaymentEvent", + BoxStyle(fill=GRAY4, fontsize=7.5), + ) + draw_arrow( + ax, + (hx + 2.9, hy + 0.2), + (hx + 0.9, hy + 0.2), + ArrowCfg( + lw=1.0, + label="extends", + label_offset=-0.3, + label_fs=7, + ), + ) + draw_arrow( + ax, + (hx + 2.9, hy + 0.2), + (hx + 5.0, hy + 0.2), + ArrowCfg( + lw=1.0, + label="extends", + label_offset=-0.3, + label_fs=7, + ), + ) + + ax.text( + 9.5, + 0.5, + "Sub C subskrybuje bazowy Event\n" "\u2192 otrzymuje WSZYSTKIE podtypy", + ha="center", + va="center", + fontsize=8.5, + style="italic", + bbox={ + "boxstyle": "round,pad=0.3", + "facecolor": GRAY4, + "edgecolor": GRAY3, + }, + ) + + save(fig, "pubsub_sub_type.png") + + +# ============================================================ +# 4. Hierarchical / Wildcards subscription +# ============================================================ +def draw_sub_hierarchical() -> None: + """Draw sub hierarchical.""" + fig, ax = plt.subplots(1, 1, figsize=(FIG_W, 5.5)) + ax.set_xlim(0, 12) + ax.set_ylim(0, 7) + ax.set_aspect("equal") + ax.axis("off") + ax.set_title( + "Subskrypcja hierarchiczna (wildcards)" " \u2014 wzorce temat\u00f3w", + fontsize=FS_TITLE, + fontweight="bold", + pad=12, + ) + + bold10 = BoxStyle(fill=GRAY2, fontsize=10, fontweight="bold") + draw_box(ax, (4.5, 5.8), (2.4, 0.8), "sensors/", bold10) + + fs9_g3 = BoxStyle(fill=GRAY3, fontsize=9) + draw_box( + ax, + (1.5, 4.2), + (2.4, 0.8), + "temperature/", + fs9_g3, + ) + draw_box( + ax, + (7.5, 4.2), + (2.4, 0.8), + "humidity/", + fs9_g3, + ) + + fs85_g4 = BoxStyle(fill=GRAY4, fontsize=8.5) + draw_box(ax, (0.2, 2.8), (1.8, 0.7), "room1", fs85_g4) + draw_box(ax, (2.4, 2.8), (1.8, 0.7), "room2", fs85_g4) + draw_box(ax, (6.8, 2.8), (1.8, 0.7), "room1", fs85_g4) + draw_box(ax, (9.0, 2.8), (1.8, 0.7), "room2", fs85_g4) + + thin = ArrowCfg(lw=1.0) + draw_arrow(ax, (5.7, 5.8), (2.7, 5.0), thin) + draw_arrow(ax, (5.7, 5.8), (8.7, 5.0), thin) + draw_arrow(ax, (2.2, 4.2), (1.1, 3.5), thin) + draw_arrow(ax, (3.2, 4.2), (3.3, 3.5), thin) + draw_arrow(ax, (8.2, 4.2), (7.7, 3.5), thin) + draw_arrow(ax, (9.2, 4.2), (9.9, 3.5), thin) + + ax.text( + 1.1, + 2.4, + "sensors/temperature/room1", + fontsize=7, + ha="center", + fontfamily="monospace", + style="italic", + ) + ax.text( + 3.3, + 2.4, + "sensors/temperature/room2", + fontsize=7, + ha="center", + fontfamily="monospace", + style="italic", + ) + + ax.text( + 0.3, + 1.5, + "Wzorce subskrypcji (MQTT-style):", + fontsize=10, + fontweight="bold", + ) + + patterns = [ + ( + '"sensors/temperature/room1"', + "\u2192 TYLKO room1", + "(dok\u0142adne dopasowanie)", + ), + ( + '"sensors/temperature/*"', + "\u2192 room1, room2", + "( * = jeden poziom)", + ), + ( + '"sensors/#"', + "\u2192 WSZYSTKO", + "( # = dowolna g\u0142\u0119boko\u015b\u0107)", + ), + ] + for i, (pat, result, note) in enumerate(patterns): + yy = 0.9 - i * 0.55 + ax.text( + 0.5, + yy, + pat, + fontsize=9, + fontweight="bold", + fontfamily="monospace", + ) + ax.text(7.0, yy, result, fontsize=9, fontweight="bold") + ax.text(9.5, yy, note, fontsize=8, style="italic") + + save(fig, "pubsub_sub_hierarchical.png") diff --git a/python_pkg/praca_magisterska_video/generate_images/_q20_architectures.py b/python_pkg/praca_magisterska_video/generate_images/_q20_architectures.py new file mode 100644 index 0000000..2f7cb65 --- /dev/null +++ b/python_pkg/praca_magisterska_video/generate_images/_q20_architectures.py @@ -0,0 +1,421 @@ +"""Spark Streaming, Lambda/Kappa architecture, and exactly-once diagrams for Q20.""" + +from __future__ import annotations + +from _q20_common import ( + FS, + FS_LABEL, + FS_SMALL, + FS_TITLE, + GRAY1, + GRAY2, + GRAY3, + GRAY4, + GRAY5, + LN, + draw_arrow, + draw_box, + draw_table, + plt, + save_fig, +) + + +# ============================================================ +# 12. Spark Streaming architecture +# ============================================================ +def gen_spark_streaming_arch() -> None: + """Gen spark streaming arch.""" + fig, ax = plt.subplots(figsize=(9, 5)) + ax.set_xlim(0, 12) + ax.set_ylim(0, 7) + ax.set_aspect("auto") + ax.axis("off") + ax.set_title( + "Spark Streaming — architektura (micro-batch)", + fontsize=FS_TITLE, + fontweight="bold", + pad=10, + ) + + # Cluster border + draw_box(ax, 0.3, 0.5, 11.4, 5.8, "", fill=GRAY4, rounded=True, lw=2.5) + ax.text( + 6.0, 6.0, "SPARK CLUSTER", fontsize=FS_LABEL, ha="center", fontweight="bold" + ) + + # Driver + draw_box( + ax, + 1.0, + 4.5, + 3.0, + 1.2, + "Driver\n(planuje mini-batche)", + fill=GRAY2, + fontsize=FS, + fontweight="bold", + ) + + draw_arrow(ax, 2.5, 4.5, 6.0, 4.0, lw=1.5) + + # Batches + batches = ["batch 1\n(e1,e2,e3)", "batch 2\n(e4,e5,e6)", "batch 3\n(e7,e8,e9)"] + for i, b in enumerate(batches): + y = 2.8 - i * 1.0 + draw_box( + ax, 4.5, y, 2.5, 0.8, b, fill=GRAY1, fontsize=FS_SMALL, fontweight="bold" + ) + # map → reduce + draw_arrow(ax, 7.0, y + 0.4, 7.5, y + 0.4, lw=1) + draw_box(ax, 7.5, y, 1.3, 0.8, "map→\nreduce", fill=GRAY3, fontsize=5.5) + draw_arrow(ax, 8.8, y + 0.4, 9.3, y + 0.4, lw=1) + draw_box( + ax, 9.3, y, 1.5, 0.8, f"result {i + 1}", fill="white", fontsize=FS_SMALL + ) + + # Spark ecosystem + draw_box( + ax, + 1.0, + 1.0, + 3.0, + 1.0, + "Spark SQL / MLlib\n(ten sam ekosystem!)", + fill=GRAY5, + fontsize=FS, + fontweight="bold", + ) + + ax.text( + 6.0, + 0.3, + "ZALETA: batch API | WADA: latencja ≥ batch interval (~100ms)", + fontsize=FS, + ha="center", + fontweight="bold", + bbox={"boxstyle": "round,pad=0.2", "facecolor": "white", "edgecolor": LN}, + ) + + save_fig(fig, "q20_spark_streaming_arch.png") + + +# ============================================================ +# 13. Lambda vs Kappa architecture +# ============================================================ +def gen_lambda_vs_kappa() -> None: + """Gen lambda vs kappa.""" + fig, axes = plt.subplots(2, 1, figsize=(10, 7)) + fig.suptitle("Architektura Lambda vs Kappa", fontsize=FS_TITLE, fontweight="bold") + + # --- Lambda --- + ax = axes[0] + ax.set_xlim(0, 12) + ax.set_ylim(0, 5) + ax.set_aspect("auto") + ax.axis("off") + ax.set_title( + "LAMBDA — 2 ścieżki (batch + speed)", fontsize=FS_LABEL, fontweight="bold" + ) + + # Source + draw_box( + ax, + 0.3, + 1.8, + 2.0, + 1.5, + "Źródło\ndanych", + fill=GRAY2, + fontsize=FS, + fontweight="bold", + ) + + # Batch layer (top) + draw_box( + ax, + 3.5, + 3.3, + 3.0, + 1.2, + "Batch Layer\n(Spark)\nprzelicza co godzinę", + fill=GRAY1, + fontsize=FS_SMALL, + fontweight="bold", + ) + draw_arrow(ax, 2.3, 3.0, 3.5, 3.9, lw=1.5) + + # Speed layer (bottom) + draw_box( + ax, + 3.5, + 0.8, + 3.0, + 1.2, + "Speed Layer\n(Flink)\nreal-time", + fill=GRAY3, + fontsize=FS_SMALL, + fontweight="bold", + ) + draw_arrow(ax, 2.3, 2.2, 3.5, 1.4, lw=1.5) + + # Results + draw_box( + ax, + 7.5, + 3.3, + 2.0, + 1.2, + "Dokładne\nwyniki\n(wolne)", + fill=GRAY4, + fontsize=FS_SMALL, + ) + draw_arrow(ax, 6.5, 3.9, 7.5, 3.9, lw=1.5) + + draw_box( + ax, + 7.5, + 0.8, + 2.0, + 1.2, + "Przybliżone\nwyniki\n(szybkie)", + fill=GRAY4, + fontsize=FS_SMALL, + ) + draw_arrow(ax, 6.5, 1.4, 7.5, 1.4, lw=1.5) + + # Merge + draw_box( + ax, + 10.0, + 2.0, + 1.5, + 1.5, + "MERGE\n→ UI", + fill=GRAY5, + fontsize=FS, + fontweight="bold", + ) + draw_arrow(ax, 9.5, 3.5, 10.0, 3.0, lw=1.5) + draw_arrow(ax, 9.5, 1.8, 10.0, 2.5, lw=1.5) + + ax.text( + 6.0, + 0.1, + "2 systemy, 2 kody — złożone ale pewne", + fontsize=FS, + ha="center", + style="italic", + bbox={"boxstyle": "round,pad=0.2", "facecolor": GRAY4, "edgecolor": GRAY5}, + ) + + # --- Kappa --- + ax = axes[1] + ax.set_xlim(0, 12) + ax.set_ylim(0, 4) + ax.set_aspect("auto") + ax.axis("off") + ax.set_title( + "KAPPA — 1 ścieżka (streaming only)", fontsize=FS_LABEL, fontweight="bold" + ) + + # Source + draw_box( + ax, + 0.3, + 1.3, + 2.0, + 1.5, + "Źródło\ndanych", + fill=GRAY2, + fontsize=FS, + fontweight="bold", + ) + + # Single streaming layer + draw_box( + ax, + 3.5, + 1.3, + 3.5, + 1.5, + "Streaming Layer\n(Flink)\n+ replay z Kafka log", + fill=GRAY1, + fontsize=FS, + fontweight="bold", + ) + draw_arrow(ax, 2.3, 2.05, 3.5, 2.05, lw=2) + + # Output + draw_box( + ax, + 8.0, + 1.3, + 2.5, + 1.5, + "Wyniki\n→ UI", + fill=GRAY4, + fontsize=FS, + fontweight="bold", + ) + draw_arrow(ax, 7.0, 2.05, 8.0, 2.05, lw=2) + + # Replay arrow + ax.annotate( + "", + xy=(3.5, 1.0), + xytext=(7.0, 1.0), + arrowprops={ + "arrowstyle": "<-", + "lw": 1.5, + "color": LN, + "connectionstyle": "arc3,rad=0.3", + "linestyle": "--", + }, + ) + ax.text( + 5.25, + 0.3, + "Replay z Kafka\n(przetwórz historię od nowa)", + fontsize=FS_SMALL, + ha="center", + style="italic", + ) + + ax.text( + 6.0, + 3.3, + "1 system, 1 kod — prostsze, ale replay = dużo I/O", + fontsize=FS, + ha="center", + style="italic", + bbox={"boxstyle": "round,pad=0.2", "facecolor": GRAY4, "edgecolor": GRAY5}, + ) + + fig.tight_layout(rect=[0, 0, 1, 0.92]) + save_fig(fig, "q20_lambda_vs_kappa.png") + + +# ============================================================ +# 14. Lambda vs Kappa comparison table +# ============================================================ +def gen_lambda_kappa_table() -> None: + """Gen lambda kappa table.""" + fig, ax = plt.subplots(figsize=(8, 3.5)) + ax.set_xlim(0, 10) + ax.set_ylim(-4.5, 1) + ax.set_aspect("auto") + ax.axis("off") + ax.set_title( + "Lambda vs Kappa — porównanie", fontsize=FS_TITLE, fontweight="bold", pad=10 + ) + + headers = ["Cecha", "Lambda", "Kappa"] + col_w = [2.5, 3.5, 3.5] + rows = [ + ["Ścieżki", "2 (batch + speed)", "1 (streaming)"], + ["Kod", "2 implementacje", "1 implementacja"], + ["Złożoność", "wysoka", "niska"], + ["Replay", "batch przelicza", "Kafka replay"], + ["Spójność", "merge wymagany", "natywna"], + ["Przykład", "Netflix, LinkedIn", "Uber, Confluent"], + ] + draw_table( + ax, headers, rows, x0=0.25, y0=0.5, col_widths=col_w, row_h=0.55, fontsize=7.5 + ) + + save_fig(fig, "q20_lambda_kappa_table.png") + + +# ============================================================ +# 15. Exactly-once comparison +# ============================================================ +def gen_exactly_once() -> None: + """Gen exactly once.""" + fig, ax = plt.subplots(figsize=(9, 5)) + ax.set_xlim(0, 12) + ax.set_ylim(0, 7) + ax.set_aspect("auto") + ax.axis("off") + ax.set_title( + "Exactly-Once — mechanizmy na 3 platformach", + fontsize=FS_TITLE, + fontweight="bold", + pad=10, + ) + + # Flink + draw_box(ax, 0.3, 4.3, 11.0, 2.0, "", fill=GRAY4, rounded=True, lw=1.5) + ax.text( + 1.0, + 5.9, + "Flink — Distributed Snapshots (Chandy-Lamport)", + fontsize=FS, + fontweight="bold", + ) + + flink_steps = ["source", "|B|", "map()", "|B|", "sink()"] + bx = 1.0 + for s in flink_steps: + if s == "|B|": + ax.text( + bx + 0.25, + 4.85, + s, + fontsize=FS, + ha="center", + fontweight="bold", + bbox={"boxstyle": "round,pad=0.1", "facecolor": GRAY5, "edgecolor": LN}, + ) + draw_arrow(ax, bx - 0.1, 4.85, bx + 0.05, 4.85, lw=1) + bx += 0.7 + else: + draw_box( + ax, + bx, + 4.6, + 1.5, + 0.55, + s, + fill=GRAY1, + fontsize=FS_SMALL, + fontweight="bold", + ) + bx += 1.8 + ax.text( + 8.5, + 5.0, + "barrier → save state\n→ checkpoint (HDFS/S3)", + fontsize=FS_SMALL, + style="italic", + ) + + # Kafka Streams + draw_box(ax, 0.3, 2.3, 11.0, 1.5, "", fill=GRAY1, rounded=True, lw=1.5) + ax.text( + 1.0, 3.5, "Kafka Streams — Transakcje Kafka", fontsize=FS, fontweight="bold" + ) + ax.text( + 1.5, + 2.85, + "idempotent producer + begin TX → produce → commit TX → consumer offsets w TX", + fontsize=FS_SMALL, + ) + + # Spark + draw_box(ax, 0.3, 0.5, 11.0, 1.5, "", fill=GRAY3, rounded=True, lw=1.5) + ax.text( + 1.0, + 1.7, + "Spark Streaming — Write-Ahead Log (WAL)", + fontsize=FS, + fontweight="bold", + ) + ax.text( + 1.5, + 1.05, + "WAL + checkpointing micro-batchów + idempotent sinks (np. upsert do DB)", + fontsize=FS_SMALL, + ) + + save_fig(fig, "q20_exactly_once.png") diff --git a/python_pkg/praca_magisterska_video/generate_images/_q20_batch_and_windows.py b/python_pkg/praca_magisterska_video/generate_images/_q20_batch_and_windows.py new file mode 100644 index 0000000..079f9cd --- /dev/null +++ b/python_pkg/praca_magisterska_video/generate_images/_q20_batch_and_windows.py @@ -0,0 +1,449 @@ +"""Batch vs streaming concept and window type diagrams for Q20.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from _q20_common import ( + FS, + FS_LABEL, + FS_SMALL, + FS_TITLE, + GRAY1, + GRAY2, + GRAY3, + GRAY4, + GRAY5, + LN, + draw_arrow, + draw_box, + plt, + save_fig, +) +import matplotlib.patches as mpatches + +if TYPE_CHECKING: + from matplotlib.axes import Axes + + +# ============================================================ +# 1. Batch vs Streaming concept +# ============================================================ +def gen_batch_vs_streaming() -> None: + """Gen batch vs streaming.""" + fig, axes = plt.subplots(2, 1, figsize=(9, 5)) + fig.suptitle( + "Batch vs Streaming — dwa modele przetwarzania", + fontsize=FS_TITLE, + fontweight="bold", + ) + + # Batch + ax = axes[0] + ax.set_xlim(0, 12) + ax.set_ylim(0, 3) + ax.set_aspect("auto") + ax.axis("off") + ax.set_title("BATCH (wsadowe)", fontsize=FS_LABEL, fontweight="bold") + + # Data collected + draw_box( + ax, + 0.5, + 0.8, + 3.0, + 1.4, + "Zbierz WSZYSTKIE\ndane\n(godziny / dni)", + fill=GRAY1, + fontsize=FS, + fontweight="bold", + ) + draw_arrow(ax, 3.5, 1.5, 4.5, 1.5, lw=2) + draw_box( + ax, + 4.5, + 0.8, + 2.5, + 1.4, + "Analiza\n(batch job)", + fill=GRAY2, + fontsize=FS, + fontweight="bold", + ) + draw_arrow(ax, 7.0, 1.5, 8.0, 1.5, lw=2) + draw_box( + ax, + 8.0, + 0.8, + 2.5, + 1.4, + "Wynik\n(jednorazowy)", + fill=GRAY3, + fontsize=FS, + fontweight="bold", + ) + ax.text(11.0, 1.5, "min-h", fontsize=FS, va="center", fontweight="bold") + + # Streaming + ax = axes[1] + ax.set_xlim(0, 12) + ax.set_ylim(0, 3) + ax.set_aspect("auto") + ax.axis("off") + ax.set_title("STREAMING (strumieniowe)", fontsize=FS_LABEL, fontweight="bold") + + # Events flowing + events_x = [0.5, 1.5, 2.5, 3.5] + for i, ex in enumerate(events_x): + draw_box( + ax, + ex, + 1.0, + 0.8, + 0.8, + f"e{i + 1}", + fill=GRAY4, + fontsize=FS, + fontweight="bold", + rounded=False, + ) + if i < len(events_x) - 1: + draw_arrow(ax, ex + 0.8, 1.4, ex + 1.0, 1.4, lw=1) + + ax.text(4.8, 1.4, "...", fontsize=FS_LABEL, va="center") + draw_arrow(ax, 5.2, 1.4, 5.8, 1.4, lw=2) + + draw_box( + ax, + 5.8, + 0.8, + 2.8, + 1.4, + "Analiza\nCIĄGŁA\n(event-by-event)", + fill=GRAY2, + fontsize=FS, + fontweight="bold", + ) + draw_arrow(ax, 8.6, 1.5, 9.3, 1.5, lw=2) + draw_box( + ax, + 9.3, + 0.8, + 2.0, + 1.4, + "Wyniki\nciągłe", + fill=GRAY3, + fontsize=FS, + fontweight="bold", + ) + ax.text(11.5, 0.5, "ms-s", fontsize=FS, va="center", fontweight="bold") + + # Arrow marking infinity + ax.annotate( + "", + xy=(0.2, 1.4), + xytext=(-0.3, 1.4), + arrowprops={"arrowstyle": "->", "lw": 1.5, "color": LN}, + ) + ax.text(0.0, 2.3, "∞ zdarzeń", fontsize=FS_SMALL, ha="center", style="italic") + + fig.tight_layout(rect=[0, 0, 1, 0.92]) + save_fig(fig, "q20_batch_vs_streaming.png") + + +# ============================================================ +# 2. All 4 window types (TSSG) +# ============================================================ +def _draw_tumbling_window(ax: Axes, events: list[int]) -> None: + """Draw tumbling window section.""" + ax.set_xlim(0, 14) + ax.set_ylim(0, 4) + ax.set_aspect("auto") + ax.axis("off") + ax.set_title( + "Tumbling Window (okno przerzutne) — rozłączne, stały rozmiar", + fontsize=FS_LABEL, + fontweight="bold", + ) + + # Time axis + ax.annotate( + "", + xy=(13.5, 1.0), + xytext=(0.3, 1.0), + arrowprops={"arrowstyle": "->", "lw": 1.5, "color": LN}, + ) + ax.text(13.5, 0.6, "czas", fontsize=FS_SMALL, ha="center") + + # Events + for i, e in enumerate(events): + x = 1.0 + i * 1.0 + ax.plot(x, 1.0, "ko", markersize=5) + ax.text(x, 0.5, f"e{e}", fontsize=FS_SMALL, ha="center") + + # Windows + colors_w = [GRAY1, GRAY3, GRAY1, GRAY3] + for w in range(4): + x_start = 1.0 + w * 3.0 - 0.3 + rect = mpatches.FancyBboxPatch( + (x_start, 1.5), + 3.0, + 1.2, + boxstyle="round,pad=0.1", + facecolor=colors_w[w], + edgecolor=LN, + lw=1.5, + ) + ax.add_patch(rect) + ax.text( + x_start + 1.5, + 2.1, + f"Okno {w + 1}", + fontsize=FS, + ha="center", + fontweight="bold", + ) + # Braces down to events + for j in range(3): + ex = 1.0 + w * 3.0 + j * 1.0 + ax.plot([ex, ex], [1.0, 1.5], color=LN, lw=0.8, linestyle="--") + + ax.text( + 7.0, + 3.2, + "Każde zdarzenie → DOKŁADNIE 1 okno. Zero nakładania.", + fontsize=FS, + ha="center", + style="italic", + bbox={"boxstyle": "round,pad=0.2", "facecolor": GRAY4, "edgecolor": GRAY5}, + ) + + +def _draw_sliding_window(ax: Axes, events: list[int]) -> None: + """Draw sliding window section.""" + ax.set_xlim(0, 14) + ax.set_ylim(0, 5) + ax.set_aspect("auto") + ax.axis("off") + ax.set_title( + "Sliding Window (okno przesuwne) — nakładające, stały rozmiar + krok", + fontsize=FS_LABEL, + fontweight="bold", + ) + + ax.annotate( + "", + xy=(13.5, 1.0), + xytext=(0.3, 1.0), + arrowprops={"arrowstyle": "->", "lw": 1.5, "color": LN}, + ) + ax.text(13.5, 0.6, "czas", fontsize=FS_SMALL, ha="center") + + for i, e in enumerate(events[:8]): + x = 1.0 + i * 1.0 + ax.plot(x, 1.0, "ko", markersize=5) + ax.text(x, 0.5, f"e{e}", fontsize=FS_SMALL, ha="center") + + # Sliding windows: size=4, slide=2 + slide_colors = [GRAY1, GRAY2, GRAY3] + for w in range(3): + x_start = 0.7 + w * 2.0 + y_base = 1.5 + w * 0.9 + rect = mpatches.FancyBboxPatch( + (x_start, y_base), + 4.0, + 0.7, + boxstyle="round,pad=0.08", + facecolor=slide_colors[w], + edgecolor=LN, + lw=1.5, + alpha=0.7, + ) + ax.add_patch(rect) + ax.text( + x_start + 2.0, + y_base + 0.35, + f"Okno {w + 1} (size=4)", + fontsize=FS_SMALL, + ha="center", + fontweight="bold", + ) + + ax.text( + 10.5, + 3.5, + "krok=2\nNakładanie!\ne3,e4 → w oknie 1 i 2", + fontsize=FS, + ha="center", + style="italic", + bbox={"boxstyle": "round,pad=0.2", "facecolor": GRAY4, "edgecolor": GRAY5}, + ) + + +def _draw_session_window(ax: Axes) -> None: + """Draw session window section.""" + ax.set_xlim(0, 14) + ax.set_ylim(0, 4) + ax.set_aspect("auto") + ax.axis("off") + ax.set_title( + "Session Window (okno sesji) — dynamiczny rozmiar, gap = przerwa", + fontsize=FS_LABEL, + fontweight="bold", + ) + + ax.annotate( + "", + xy=(13.5, 1.0), + xytext=(0.3, 1.0), + arrowprops={"arrowstyle": "->", "lw": 1.5, "color": LN}, + ) + ax.text(13.5, 0.6, "czas", fontsize=FS_SMALL, ha="center") + + # Cluster 1: events close together + cluster1 = [1.0, 1.8, 2.3, 3.0] + for x in cluster1: + ax.plot(x, 1.0, "ko", markersize=5) + + # Gap + ax.annotate( + "", + xy=(7.0, 0.7), + xytext=(4.0, 0.7), + arrowprops={"arrowstyle": "<->", "lw": 1, "color": LN}, + ) + ax.text( + 5.5, + 0.3, + "GAP > timeout", + fontsize=FS, + ha="center", + fontweight="bold", + style="italic", + ) + + # Cluster 2 + cluster2 = [8.0, 8.8, 9.5] + for x in cluster2: + ax.plot(x, 1.0, "ko", markersize=5) + + # Session boxes + rect1 = mpatches.FancyBboxPatch( + (0.7, 1.4), + 2.6, + 1.0, + boxstyle="round,pad=0.1", + facecolor=GRAY1, + edgecolor=LN, + lw=1.5, + ) + ax.add_patch(rect1) + ax.text( + 2.0, 1.9, "Sesja 1\n(4 zdarzenia)", fontsize=FS, ha="center", fontweight="bold" + ) + + rect2 = mpatches.FancyBboxPatch( + (7.7, 1.4), + 2.1, + 1.0, + boxstyle="round,pad=0.1", + facecolor=GRAY3, + edgecolor=LN, + lw=1.5, + ) + ax.add_patch(rect2) + ax.text( + 8.75, 1.9, "Sesja 2\n(3 zdarzenia)", fontsize=FS, ha="center", fontweight="bold" + ) + + ax.text( + 5.5, + 3.0, + "Nowa sesja po przerwie > gap", + fontsize=FS, + ha="center", + style="italic", + ) + + +def _draw_global_window(ax: Axes) -> None: + """Draw global window section.""" + ax.set_xlim(0, 14) + ax.set_ylim(0, 4) + ax.set_aspect("auto") + ax.axis("off") + ax.set_title( + "Global Window — jedno okno na cały strumień + trigger", + fontsize=FS_LABEL, + fontweight="bold", + ) + + ax.annotate( + "", + xy=(13.5, 1.0), + xytext=(0.3, 1.0), + arrowprops={"arrowstyle": "->", "lw": 1.5, "color": LN}, + ) + ax.text(13.5, 0.6, "czas", fontsize=FS_SMALL, ha="center") + + for i in range(12): + x = 1.0 + i * 1.0 + ax.plot(x, 1.0, "ko", markersize=5) + + # One big window + rect = mpatches.FancyBboxPatch( + (0.5, 1.4), + 12.5, + 1.0, + boxstyle="round,pad=0.1", + facecolor=GRAY1, + edgecolor=LN, + lw=2, + ) + ax.add_patch(rect) + ax.text( + 6.75, + 1.9, + "GLOBAL WINDOW (cały strumień)", + fontsize=FS, + ha="center", + fontweight="bold", + ) + + # Trigger markers + for tx in [4.0, 8.0, 12.0]: + ax.plot([tx, tx], [1.4, 2.4], color=LN, lw=2, linestyle="--") + ax.text( + tx, + 2.7, + "EMIT", + fontsize=FS_SMALL, + ha="center", + fontweight="bold", + bbox={"boxstyle": "round,pad=0.1", "facecolor": GRAY3, "edgecolor": LN}, + ) + + ax.text( + 6.75, + 3.3, + "Trigger decyduje kiedy emitować (np. co N zdarzeń)", + fontsize=FS, + ha="center", + style="italic", + ) + + +def gen_window_types() -> None: + """Gen window types.""" + fig, axes = plt.subplots(4, 1, figsize=(9, 10)) + fig.suptitle("4 typy okien — TSSG", fontsize=FS_TITLE, fontweight="bold") + + events = list(range(1, 13)) + + _draw_tumbling_window(axes[0], events) + _draw_sliding_window(axes[1], events) + _draw_session_window(axes[2]) + _draw_global_window(axes[3]) + + fig.tight_layout(rect=[0, 0, 1, 0.94]) + save_fig(fig, "q20_window_types.png") diff --git a/python_pkg/praca_magisterska_video/generate_images/_q20_common.py b/python_pkg/praca_magisterska_video/generate_images/_q20_common.py new file mode 100644 index 0000000..bb11c63 --- /dev/null +++ b/python_pkg/praca_magisterska_video/generate_images/_q20_common.py @@ -0,0 +1,180 @@ +"""Common utilities and constants for Q20 diagram generation. + +Monochrome, A4-printable PNGs (300 DPI). +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import TYPE_CHECKING + +import matplotlib as mpl + +mpl.use("Agg") + +import matplotlib.patches as mpatches +from matplotlib.patches import FancyBboxPatch +import matplotlib.pyplot as plt +import numpy as np + +if TYPE_CHECKING: + from matplotlib.axes import Axes + from matplotlib.figure import Figure + +_logger = logging.getLogger(__name__) + +rng = np.random.default_rng(42) + +DPI = 300 +BG = "white" +LN = "black" +FS = 8 +FS_TITLE = 11 +FS_SMALL = 6.5 +FS_LABEL = 9 +OUTPUT_DIR = str(Path(__file__).resolve().parent / "img") +Path(OUTPUT_DIR).mkdir(parents=True, exist_ok=True) + +GRAY1 = "#E8E8E8" +GRAY2 = "#D0D0D0" +GRAY3 = "#B8B8B8" +GRAY4 = "#F5F5F5" +GRAY5 = "#C0C0C0" + + +def draw_box( + ax: Axes, + x: float, + y: float, + w: float, + h: float, + text: str, + *, + fill: str = "white", + lw: float = 1.2, + fontsize: float = FS, + fontweight: str = "normal", + ha: str = "center", + va: str = "center", + rounded: bool = True, + edgecolor: str = LN, + linestyle: str = "-", +) -> None: + """Draw box.""" + if rounded: + rect = FancyBboxPatch( + (x, y), + w, + h, + boxstyle="round,pad=0.05", + lw=lw, + edgecolor=edgecolor, + facecolor=fill, + linestyle=linestyle, + ) + else: + rect = mpatches.Rectangle( + (x, y), + w, + h, + lw=lw, + edgecolor=edgecolor, + facecolor=fill, + linestyle=linestyle, + ) + ax.add_patch(rect) + ax.text( + x + w / 2, + y + h / 2, + text, + ha=ha, + va=va, + fontsize=fontsize, + fontweight=fontweight, + wrap=True, + ) + + +def draw_arrow( + ax: Axes, + x1: float, + y1: float, + x2: float, + y2: float, + lw: float = 1.2, + style: str = "->", + color: str = LN, +) -> None: + """Draw arrow.""" + ax.annotate( + "", + xy=(x2, y2), + xytext=(x1, y1), + arrowprops={"arrowstyle": style, "color": color, "lw": lw}, + ) + + +def save_fig(fig: Figure, name: str) -> None: + """Save fig.""" + path = str(Path(OUTPUT_DIR) / name) + fig.savefig(path, dpi=DPI, bbox_inches="tight", facecolor=BG, pad_inches=0.15) + plt.close(fig) + _logger.info(" Saved: %s", path) + + +def draw_table( + ax: Axes, + headers: list[str], + rows: list[list[str]], + x0: float, + y0: float, + col_widths: list[float], + row_h: float = 0.4, + header_fill: str = GRAY2, + row_fills: list[str] | None = None, + fontsize: float = FS, + header_fontsize: float | None = None, +) -> None: + """Draw table.""" + if header_fontsize is None: + header_fontsize = fontsize + len(headers) + # Header + cx = x0 + for j, hdr in enumerate(headers): + draw_box( + ax, + cx, + y0, + col_widths[j], + row_h, + hdr, + fill=header_fill, + fontsize=header_fontsize, + fontweight="bold", + rounded=False, + ) + cx += col_widths[j] + # Rows + for i, row in enumerate(rows): + cy = y0 - (i + 1) * row_h + cx = x0 + fill = GRAY4 if (i % 2 == 0) else "white" + if row_fills and i < len(row_fills): + fill = row_fills[i] + for j, cell in enumerate(row): + fw = "bold" if j == 0 else "normal" + draw_box( + ax, + cx, + cy, + col_widths[j], + row_h, + cell, + fill=fill, + fontsize=fontsize, + fontweight=fw, + rounded=False, + ) + cx += col_widths[j] diff --git a/python_pkg/praca_magisterska_video/generate_images/_q20_late_and_decisions.py b/python_pkg/praca_magisterska_video/generate_images/_q20_late_and_decisions.py new file mode 100644 index 0000000..55adfb3 --- /dev/null +++ b/python_pkg/praca_magisterska_video/generate_images/_q20_late_and_decisions.py @@ -0,0 +1,240 @@ +"""Late data strategies and decision tree diagrams for Q20.""" + +from __future__ import annotations + +from _q20_common import ( + FS, + FS_LABEL, + FS_SMALL, + FS_TITLE, + GRAY1, + GRAY2, + GRAY3, + GRAY4, + GRAY5, + draw_arrow, + draw_box, + plt, + save_fig, +) + + +# ============================================================ +# 16. Late data strategies (DRAS) +# ============================================================ +def gen_late_data_strategies() -> None: + """Gen late data strategies.""" + fig, ax = plt.subplots(figsize=(9, 5.5)) + ax.set_xlim(0, 12) + ax.set_ylim(0, 7) + ax.set_aspect("auto") + ax.axis("off") + ax.set_title( + "Late Data — 4 strategie (mnemonik DRAS)", + fontsize=FS_TITLE, + fontweight="bold", + pad=10, + ) + + # Setup: window closed, late event arrives + draw_box( + ax, + 0.5, + 5.5, + 4.5, + 1.0, + "Okno [14:00-14:05]\nZAMKNIĘTE o 14:05", + fill=GRAY2, + fontsize=FS, + fontweight="bold", + ) + draw_box( + ax, + 6.0, + 5.5, + 4.5, + 1.0, + "Spóźnione zdarzenie\nevent_time=14:00:03\narrives=14:05:30", + fill="#F8D7DA", + fontsize=FS_SMALL, + fontweight="bold", + ) + draw_arrow(ax, 10.5, 6.0, 5.0, 6.0, lw=2, color="#C62828", style="->") + ax.text( + 7.5, + 5.2, + "LATE!", + fontsize=FS_LABEL, + ha="center", + fontweight="bold", + color="#C62828", + ) + + # 4 strategies + strategies = [ + ("D — Drop", "Odrzuć spóźnione", "/dev/null", GRAY4), + ("R — Recompute", "Przelicz okno ponownie", "poprawne ale kosztowne", GRAY1), + ( + "A — Allowed lateness", + "Czekaj dodatkowy czas\n(np. +2 min)", + "kompromis pamięci", + GRAY2, + ), + ( + "S — Side output", + "Przekieruj do osobnej\nkolejki", + "elastyczne, ręczna analiza", + GRAY3, + ), + ] + for i, (name, desc, tradeoff, color) in enumerate(strategies): + y = 3.8 - i * 1.1 + draw_box(ax, 0.5, y, 2.5, 0.9, name, fill=color, fontsize=FS, fontweight="bold") + ax.text(3.3, y + 0.45, desc, fontsize=FS_SMALL, va="center") + ax.text( + 8.5, + y + 0.45, + tradeoff, + fontsize=FS_SMALL, + va="center", + style="italic", + color="#555", + ) + + save_fig(fig, "q20_late_data_strategies.png") + + +# ============================================================ +# 17. Decision tree — which platform +# ============================================================ +def gen_decision_tree() -> None: + """Gen decision tree.""" + fig, ax = plt.subplots(figsize=(10, 5.5)) + ax.set_xlim(0, 12) + ax.set_ylim(0, 7) + ax.set_aspect("auto") + ax.axis("off") + ax.set_title( + "Drzewo decyzyjne — wybór platformy", + fontsize=FS_TITLE, + fontweight="bold", + pad=10, + ) + + # Root question + draw_box( + ax, + 3.5, + 5.5, + 4.5, + 1.0, + "Latencja < 10ms\nwymagana?", + fill=GRAY2, + fontsize=FS, + fontweight="bold", + ) + + # TAK branch + draw_arrow(ax, 3.5, 5.7, 2.0, 5.0, lw=1.5) + ax.text(2.3, 5.3, "TAK", fontsize=FS, fontweight="bold") + + draw_box( + ax, + 0.3, + 3.5, + 3.5, + 1.0, + "Dane już w Kafce?\nProste transformacje?", + fill=GRAY1, + fontsize=FS, + fontweight="bold", + ) + + # TAK → Kafka Streams + draw_arrow(ax, 0.3, 3.7, -0.1, 3.0, lw=1.5) + ax.text(0.0, 3.3, "TAK", fontsize=FS_SMALL, fontweight="bold") + draw_box( + ax, + -0.3, + 1.8, + 2.5, + 1.0, + "Kafka\nStreams", + fill=GRAY5, + fontsize=FS_LABEL, + fontweight="bold", + ) + + # NIE → Flink + draw_arrow(ax, 3.8, 3.7, 4.5, 3.0, lw=1.5) + ax.text(4.0, 3.3, "NIE\n(złożona logika)", fontsize=FS_SMALL) + draw_box( + ax, + 3.0, + 1.8, + 2.5, + 1.0, + "Apache\nFlink", + fill=GRAY5, + fontsize=FS_LABEL, + fontweight="bold", + ) + + # NIE branch + draw_arrow(ax, 8.0, 5.7, 9.5, 5.0, lw=1.5) + ax.text(8.7, 5.3, "NIE", fontsize=FS, fontweight="bold") + + draw_box( + ax, + 7.5, + 3.5, + 4.2, + 1.0, + "~100ms-1s OK?\nPotrzeba ML / SQL?", + fill=GRAY1, + fontsize=FS, + fontweight="bold", + ) + + # TAK + ML → Spark + draw_arrow(ax, 9.5, 3.5, 9.5, 3.0, lw=1.5) + ax.text(10.0, 3.3, "TAK + ML/SQL", fontsize=FS_SMALL) + draw_box( + ax, + 8.0, + 1.8, + 2.5, + 1.0, + "Spark\nStreaming", + fill=GRAY5, + fontsize=FS_LABEL, + fontweight="bold", + ) + + # TAK + proste → Kafka Streams too + draw_arrow(ax, 7.5, 3.7, 6.5, 3.0, lw=1.5) + ax.text(6.3, 3.3, "proste + TAK", fontsize=FS_SMALL) + draw_box( + ax, + 5.8, + 1.8, + 2.0, + 1.0, + "Kafka\nStreams", + fill=GRAY5, + fontsize=FS, + fontweight="bold", + ) + + # Legend + ax.text( + 6.0, + 0.7, + "Reguła: Kafka Streams = najprostsze (library) | " + "Flink = najpotężniejszy (true streaming) | Spark = ekosystem ML", + fontsize=FS, + ha="center", + bbox={"boxstyle": "round,pad=0.2", "facecolor": GRAY4, "edgecolor": GRAY5}, + ) + + save_fig(fig, "q20_decision_tree.png") diff --git a/python_pkg/praca_magisterska_video/generate_images/_q20_platforms.py b/python_pkg/praca_magisterska_video/generate_images/_q20_platforms.py new file mode 100644 index 0000000..e7351ec --- /dev/null +++ b/python_pkg/praca_magisterska_video/generate_images/_q20_platforms.py @@ -0,0 +1,471 @@ +"""Streaming ecosystem, micro-batch, platform comparison, and engine diagrams for Q20.""" + +from __future__ import annotations + +from _q20_common import ( + FS, + FS_LABEL, + FS_SMALL, + FS_TITLE, + GRAY1, + GRAY2, + GRAY3, + GRAY4, + GRAY5, + LN, + draw_arrow, + draw_box, + draw_table, + plt, + save_fig, +) + + +# ============================================================ +# 7. Streaming ecosystem overview +# ============================================================ +def gen_streaming_ecosystem() -> None: + """Gen streaming ecosystem.""" + fig, ax = plt.subplots(figsize=(10, 5.5)) + ax.set_xlim(0, 12) + ax.set_ylim(0, 7) + ax.set_aspect("auto") + ax.axis("off") + ax.set_title( + "Ekosystem przetwarzania strumieniowego", + fontsize=FS_TITLE, + fontweight="bold", + pad=10, + ) + + # Source + draw_box( + ax, + 0.3, + 2.5, + 2.0, + 3.0, + "Kafka\nTopics\n(źródło)", + fill=GRAY2, + fontsize=FS, + fontweight="bold", + ) + + # Engines + engines = [ + ("Kafka Streams\n(library w JVM)", GRAY1, 4.7), + ("Apache Flink\n(klaster)", GRAY3, 3.2), + ("Spark Streaming\n(klaster)", GRAY5, 1.7), + ] + for label, color, y in engines: + draw_box( + ax, 4.0, y, 3.0, 1.2, label, fill=color, fontsize=FS, fontweight="bold" + ) + draw_arrow(ax, 2.3, 4.0, 4.0, y + 0.6, lw=1.5) + + # Sinks + sinks = [ + ("Kafka topic\n/ baza danych", GRAY4, 4.7), + ("DB / Kafka\n/ S3", GRAY4, 3.2), + ("HDFS / DB\n/ dashboard", GRAY4, 1.7), + ] + for label, color, y in sinks: + draw_box(ax, 8.5, y, 2.5, 1.2, label, fill=color, fontsize=FS) + draw_arrow(ax, 7.0, y + 0.6, 8.5, y + 0.6, lw=1.5) + + # Labels + ax.text(1.3, 6.0, "ŹRÓDŁO", fontsize=FS_LABEL, ha="center", fontweight="bold") + ax.text(5.5, 6.2, "SILNIK", fontsize=FS_LABEL, ha="center", fontweight="bold") + ax.text(9.75, 6.2, "WYNIK", fontsize=FS_LABEL, ha="center", fontweight="bold") + + # Latency annotations + ax.text(5.5, 5.95, "~1-10 ms", fontsize=FS_SMALL, ha="center", style="italic") + ax.text(5.5, 4.5, "<10 ms", fontsize=FS_SMALL, ha="center", style="italic") + ax.text(5.5, 3.0, "~100 ms", fontsize=FS_SMALL, ha="center", style="italic") + + save_fig(fig, "q20_streaming_ecosystem.png") + + +# ============================================================ +# 8. True streaming vs Micro-batch +# ============================================================ +def gen_true_vs_microbatch() -> None: + """Gen true vs microbatch.""" + fig, axes = plt.subplots(2, 1, figsize=(10, 5.5)) + fig.suptitle("True Streaming vs Micro-Batch", fontsize=FS_TITLE, fontweight="bold") + + # True streaming + ax = axes[0] + ax.set_xlim(0, 12) + ax.set_ylim(0, 3.5) + ax.set_aspect("auto") + ax.axis("off") + ax.set_title( + "TRUE STREAMING (Flink, Kafka Streams) — event-by-event", + fontsize=FS_LABEL, + fontweight="bold", + ) + + for i in range(6): + x = 1.0 + i * 1.8 + # Event + draw_box( + ax, + x, + 2.0, + 0.8, + 0.7, + f"e{i + 1}", + fill=GRAY1, + fontsize=FS, + fontweight="bold", + rounded=False, + ) + # Arrow down + draw_arrow(ax, x + 0.4, 2.0, x + 0.4, 1.4, lw=1) + # Result + draw_box( + ax, + x, + 0.5, + 0.8, + 0.7, + f"r{i + 1}", + fill=GRAY3, + fontsize=FS, + fontweight="bold", + rounded=False, + ) + # Latency label + ax.text(x + 0.4, 1.6, "~ms", fontsize=5, ha="center", color="#555") + + ax.text( + 11.5, + 1.3, + "Latencja:\n< 10 ms", + fontsize=FS, + ha="center", + fontweight="bold", + bbox={"boxstyle": "round,pad=0.2", "facecolor": GRAY4, "edgecolor": GRAY5}, + ) + + # Micro-batch + ax = axes[1] + ax.set_xlim(0, 12) + ax.set_ylim(0, 3.5) + ax.set_aspect("auto") + ax.axis("off") + ax.set_title( + "MICRO-BATCH (Spark Streaming) — grupami co ~100ms", + fontsize=FS_LABEL, + fontweight="bold", + ) + + batch_colors = [GRAY1, GRAY2, GRAY3] + for b in range(3): + bx = 0.8 + b * 3.5 + # Batch boundary + draw_box(ax, bx, 1.8, 3.0, 1.0, "", fill=batch_colors[b], rounded=True, lw=1.5) + ax.text( + bx + 1.5, 2.6, f"Batch {b + 1}", fontsize=FS, ha="center", fontweight="bold" + ) + for j in range(3): + ex = bx + 0.3 + j * 0.9 + draw_box( + ax, + ex, + 2.0, + 0.7, + 0.5, + f"e{b * 3 + j + 1}", + fill="white", + fontsize=FS_SMALL, + rounded=False, + ) + + # Arrow down + draw_arrow(ax, bx + 1.5, 1.8, bx + 1.5, 1.2, lw=1.5) + # Result + draw_box( + ax, + bx + 0.5, + 0.4, + 2.0, + 0.7, + f"result {b + 1}", + fill=GRAY4, + fontsize=FS, + fontweight="bold", + ) + + ax.text( + 11.5, + 1.3, + "Latencja:\n~100ms-s", + fontsize=FS, + ha="center", + fontweight="bold", + bbox={"boxstyle": "round,pad=0.2", "facecolor": GRAY4, "edgecolor": GRAY5}, + ) + + fig.tight_layout(rect=[0, 0, 1, 0.92]) + save_fig(fig, "q20_true_vs_microbatch.png") + + +# ============================================================ +# 9. Platform comparison table +# ============================================================ +def gen_platform_comparison() -> None: + """Gen platform comparison.""" + fig, ax = plt.subplots(figsize=(9, 5)) + ax.set_xlim(0, 11.5) + ax.set_ylim(-6, 1) + ax.set_aspect("auto") + ax.axis("off") + ax.set_title( + "Porównanie platform strumieniowych", + fontsize=FS_TITLE, + fontweight="bold", + pad=10, + ) + + headers = ["Cecha", "Kafka Streams", "Apache Flink", "Spark Streaming"] + col_w = [2.5, 2.8, 2.8, 2.8] + rows = [ + ["Model", "event-by-event", "event-by-event", "micro-batch (~100ms)"], + ["Deployment", "library (w JVM)", "klaster", "klaster"], + ["Latencja", "~1-10 ms", "< 10 ms", "100 ms - sekundy"], + ["Exactly-once", "Kafka TXN", "checkpointing", "WAL"], + ["State", "RocksDB local", "RocksDB + ckpt", "in-memory / ext"], + ["Okna", "T, S, Session", "wszystkie + custom", "T, S"], + ["Use case", "Kafka → Kafka", "złożona analityka", "ETL + ML / SQL"], + ] + draw_table( + ax, + headers, + rows, + x0=0.25, + y0=0.5, + col_widths=col_w, + row_h=0.6, + fontsize=7, + header_fontsize=8, + ) + + save_fig(fig, "q20_platform_comparison.png") + + +# ============================================================ +# 10. Kafka Streams architecture +# ============================================================ +def gen_kafka_streams_arch() -> None: + """Gen kafka streams arch.""" + fig, ax = plt.subplots(figsize=(9, 5)) + ax.set_xlim(0, 12) + ax.set_ylim(0, 7) + ax.set_aspect("auto") + ax.axis("off") + ax.set_title( + "Kafka Streams — architektura (library w JVM)", + fontsize=FS_TITLE, + fontweight="bold", + pad=10, + ) + + # Outer box: Your Java application + draw_box(ax, 0.5, 0.5, 11.0, 5.5, "", fill=GRAY4, rounded=True, lw=2.5) + ax.text( + 6.0, + 5.7, + "Twoja aplikacja Java (JVM)", + fontsize=FS_LABEL, + ha="center", + fontweight="bold", + ) + + # Kafka Consumer + draw_box( + ax, + 1.0, + 3.0, + 2.5, + 1.5, + "Kafka\nConsumer\n(input topic)", + fill=GRAY1, + fontsize=FS, + fontweight="bold", + ) + + # Processing + draw_box( + ax, + 4.5, + 3.0, + 2.5, + 1.5, + "Kafka Streams\n(logika\nbiznesowa)", + fill=GRAY2, + fontsize=FS, + fontweight="bold", + ) + + # Kafka Producer + draw_box( + ax, + 8.0, + 3.0, + 2.5, + 1.5, + "Kafka\nProducer\n(output topic)", + fill=GRAY1, + fontsize=FS, + fontweight="bold", + ) + + # Arrows + draw_arrow(ax, 3.5, 3.75, 4.5, 3.75, lw=2) + draw_arrow(ax, 7.0, 3.75, 8.0, 3.75, lw=2) + + # RocksDB state store + draw_box( + ax, + 4.5, + 1.0, + 2.5, + 1.3, + "RocksDB\n(stan lokalny)", + fill=GRAY3, + fontsize=FS, + fontweight="bold", + ) + ax.plot([5.75, 5.75], [3.0, 2.3], color=LN, lw=1.5) + ax.text( + 7.3, + 1.6, + "okna, joiny,\nagregacje", + fontsize=FS_SMALL, + style="italic", + va="center", + ) + + # Key message + ax.text( + 6.0, + 0.2, + "NIE potrzebujesz osobnego klastra! Skalujesz = więcej instancji JVM.", + fontsize=FS, + ha="center", + fontweight="bold", + bbox={"boxstyle": "round,pad=0.2", "facecolor": "white", "edgecolor": LN}, + ) + + save_fig(fig, "q20_kafka_streams_arch.png") + + +# ============================================================ +# 11. Flink architecture + checkpointing +# ============================================================ +def gen_flink_arch() -> None: + """Gen flink arch.""" + fig, ax = plt.subplots(figsize=(9, 6)) + ax.set_xlim(0, 12) + ax.set_ylim(0, 8) + ax.set_aspect("auto") + ax.axis("off") + ax.set_title( + "Apache Flink — architektura klastra + checkpointing", + fontsize=FS_TITLE, + fontweight="bold", + pad=10, + ) + + # Cluster border + draw_box(ax, 0.3, 1.0, 11.4, 6.2, "", fill=GRAY4, rounded=True, lw=2.5) + ax.text( + 6.0, 6.95, "FLINK CLUSTER", fontsize=FS_LABEL, ha="center", fontweight="bold" + ) + + # Job Manager + draw_box( + ax, + 1.0, + 5.5, + 3.0, + 1.2, + "Job Manager\n(koordynacja,\ncheckpointy)", + fill=GRAY2, + fontsize=FS, + fontweight="bold", + ) + + # Task Managers + draw_box(ax, 1.0, 3.0, 10.0, 2.0, "", fill="white", rounded=True, lw=1.5) + ax.text( + 6.0, 4.7, "Task Managers (workery)", fontsize=FS, ha="center", fontweight="bold" + ) + + slots = ["source\n& map()", "map()", "window()\n& reduce", "sink()"] + for i, s in enumerate(slots): + x = 1.5 + i * 2.4 + draw_box( + ax, + x, + 3.3, + 2.0, + 1.2, + f"Slot {i + 1}\n{s}", + fill=GRAY1, + fontsize=FS_SMALL, + fontweight="bold", + ) + + draw_arrow(ax, 2.5, 5.5, 6.0, 5.0, lw=1.5, style="->") + ax.text(5.0, 5.5, "przydziela\npodzadania", fontsize=FS_SMALL, style="italic") + + # Checkpoint storage + draw_box( + ax, + 5.5, + 1.2, + 3.5, + 1.2, + "Checkpoint Storage\n(HDFS / S3)", + fill=GRAY3, + fontsize=FS, + fontweight="bold", + ) + ax.plot([7.25, 7.25], [2.4, 3.3], color=LN, lw=1.5, linestyle="--") + ax.text(8.0, 2.7, "snapshoty\nstanu", fontsize=FS_SMALL, style="italic") + + # Barrier concept at bottom + ax.text(3.0, 1.6, "Barrier:", fontsize=FS, fontweight="bold") + barrier_boxes = ["source", "|B|", "map", "|B|", "sink"] + bx = 0.8 + for _i, b in enumerate(barrier_boxes): + if b == "|B|": + ax.text( + bx + 0.3, + 1.5, + b, + fontsize=FS, + ha="center", + fontweight="bold", + bbox={"boxstyle": "round,pad=0.1", "facecolor": GRAY5, "edgecolor": LN}, + ) + draw_arrow(ax, bx, 1.5, bx + 0.1, 1.5, lw=1) + bx += 0.7 + else: + draw_box( + ax, + bx, + 1.3, + 1.0, + 0.45, + b, + fill=GRAY1, + fontsize=FS_SMALL, + fontweight="bold", + ) + bx += 1.2 + + save_fig(fig, "q20_flink_arch.png") diff --git a/python_pkg/praca_magisterska_video/generate_images/_q20_time_monitoring_sessions.py b/python_pkg/praca_magisterska_video/generate_images/_q20_time_monitoring_sessions.py new file mode 100644 index 0000000..0bec93a --- /dev/null +++ b/python_pkg/praca_magisterska_video/generate_images/_q20_time_monitoring_sessions.py @@ -0,0 +1,464 @@ +"""Event time, fraud detection, SLA monitoring, and session diagrams for Q20.""" + +from __future__ import annotations + +from _q20_common import ( + FS, + FS_LABEL, + FS_SMALL, + FS_TITLE, + GRAY1, + GRAY3, + GRAY4, + GRAY5, + LN, + draw_box, + np, + plt, + rng, + save_fig, +) +import matplotlib.patches as mpatches + + +# ============================================================ +# 3. Event Time vs Processing Time scatter + watermark +# ============================================================ +def gen_event_vs_processing_time() -> None: + """Gen event vs processing time.""" + fig, axes = plt.subplots(1, 2, figsize=(11, 5)) + fig.suptitle( + "Event Time vs Processing Time + Watermark", + fontsize=FS_TITLE, + fontweight="bold", + ) + + # --- Panel 1: Ideal vs Real --- + ax = axes[0] + ax.set_xlim(0, 10) + ax.set_ylim(0, 10) + ax.set_aspect("equal") + ax.set_xlabel("Event Time", fontsize=FS_LABEL) + ax.set_ylabel("Processing Time", fontsize=FS_LABEL) + ax.set_title("Idealny vs Realny świat", fontsize=FS_LABEL, fontweight="bold") + ax.set_xticks([]) + ax.set_yticks([]) + + # Ideal line + ax.plot([0, 9], [0, 9], "k--", lw=1.5, label="ideał (brak opóźnień)") + + # Real scattered points (processing >= event, some out of order) + event_times = np.sort(rng.uniform(1, 8, 15)) + proc_times = event_times + rng.exponential(0.5, 15) + # Make some out of order + idx = [3, 7, 11] + for i in idx: + proc_times[i] += 1.5 + + ax.scatter( + event_times, proc_times, c="black", s=30, zorder=5, label="zdarzenia (realne)" + ) + + # Highlight out-of-order + for i in idx: + ax.annotate( + "out-of-order", + xy=(event_times[i], proc_times[i]), + xytext=(event_times[i] + 0.8, proc_times[i] + 0.5), + fontsize=FS_SMALL, + ha="left", + arrowprops={"arrowstyle": "->", "lw": 0.8, "color": "#555"}, + ) + + ax.legend(fontsize=FS_SMALL, loc="upper left") + ax.text( + 7, + 2, + "Opóźnienie\nsieciowe ↑", + fontsize=FS, + ha="center", + style="italic", + color="#555", + ) + + # --- Panel 2: Watermark concept --- + ax = axes[1] + ax.set_xlim(0, 10) + ax.set_ylim(0, 10) + ax.set_aspect("equal") + ax.set_xlabel("Event Time", fontsize=FS_LABEL) + ax.set_ylabel("Processing Time", fontsize=FS_LABEL) + ax.set_title("Watermark — granica postępu", fontsize=FS_LABEL, fontweight="bold") + ax.set_xticks([]) + ax.set_yticks([]) + + # Events + ax.scatter(event_times, proc_times, c="black", s=30, zorder=5) + + # Watermark line (below most points, tracks progress) + wm_x = np.linspace(0, 9, 50) + wm_y = wm_x + 0.3 # watermark slightly above ideal + ax.plot(wm_x, wm_y, "k-", lw=2.5, label="Watermark") + ax.fill_between(wm_x, 0, wm_y, alpha=0.15, color="gray") + + ax.text( + 2.0, + 1.0, + 'PONIŻEJ watermark:\n„na pewno dotarło"', + fontsize=FS, + ha="center", + fontweight="bold", + bbox={"boxstyle": "round,pad=0.2", "facecolor": GRAY4, "edgecolor": GRAY5}, + ) + + # Late event + late_x, late_y = event_times[7], proc_times[7] + ax.scatter( + [late_x], [late_y], c="white", s=80, zorder=6, edgecolors="black", linewidths=2 + ) + ax.annotate( + "LATE DATA!\n(po watermarku)", + xy=(late_x, late_y), + xytext=(late_x + 1.2, late_y + 0.8), + fontsize=FS_SMALL, + ha="left", + fontweight="bold", + arrowprops={"arrowstyle": "->", "lw": 1, "color": LN}, + ) + + ax.legend(fontsize=FS_SMALL, loc="upper left") + + fig.tight_layout(rect=[0, 0, 1, 0.92]) + save_fig(fig, "q20_event_vs_processing_time.png") + + +# ============================================================ +# 4. Tumbling window example — fraud detection +# ============================================================ +def gen_tumbling_fraud() -> None: + """Gen tumbling fraud.""" + fig, ax = plt.subplots(figsize=(9, 4)) + ax.set_xlim(0, 12) + ax.set_ylim(0, 5.5) + ax.set_aspect("auto") + ax.axis("off") + ax.set_title( + "Tumbling Window — fraud detection (okno = 1 min)", + fontsize=FS_TITLE, + fontweight="bold", + pad=10, + ) + + # Time axis + ax.annotate( + "", + xy=(11.5, 1.0), + xytext=(0.5, 1.0), + arrowprops={"arrowstyle": "->", "lw": 1.5, "color": LN}, + ) + ax.text(6.0, 0.4, "czas", fontsize=FS, ha="center") + + # Window 1: normal + draw_box(ax, 1.0, 1.5, 4.5, 3.0, "", fill=GRAY4, rounded=True, lw=2) + ax.text( + 3.25, 4.2, "[14:00 — 14:01]", fontsize=FS_LABEL, ha="center", fontweight="bold" + ) + # Transactions + txns1 = ["Sklep A: 50 zł", "Sklep B: 30 zł", "Stacja: 80 zł"] + for i, t in enumerate(txns1): + draw_box( + ax, + 1.3, + 3.3 - i * 0.55, + 4.0, + 0.45, + t, + fill=GRAY1, + fontsize=FS_SMALL, + rounded=False, + ) + ax.text( + 3.25, + 1.7, + "count = 3 → OK", + fontsize=FS, + ha="center", + fontweight="bold", + color="#2E7D32", + bbox={ + "boxstyle": "round,pad=0.15", + "facecolor": "#E8F5E9", + "edgecolor": "#2E7D32", + }, + ) + + # Window 2: fraud! + draw_box(ax, 6.0, 1.5, 4.5, 3.0, "", fill=GRAY1, rounded=True, lw=2) + ax.text( + 8.25, 4.2, "[14:01 — 14:02]", fontsize=FS_LABEL, ha="center", fontweight="bold" + ) + txns2 = ["ATM Warszawa: 500 zł", "ATM Kraków: 500 zł", "... +45 transakcji"] + for i, t in enumerate(txns2): + draw_box( + ax, + 6.3, + 3.3 - i * 0.55, + 4.0, + 0.45, + t, + fill=GRAY3, + fontsize=FS_SMALL, + rounded=False, + ) + ax.text( + 8.25, + 1.7, + "count = 47 → ALERT!", + fontsize=FS, + ha="center", + fontweight="bold", + color="#C62828", + bbox={ + "boxstyle": "round,pad=0.15", + "facecolor": "#F8D7DA", + "edgecolor": "#C62828", + }, + ) + + save_fig(fig, "q20_tumbling_fraud.png") + + +# ============================================================ +# 5. Sliding window — SLA monitoring +# ============================================================ +def gen_sliding_sla() -> None: + """Gen sliding sla.""" + fig, ax = plt.subplots(figsize=(9, 4.5)) + ax.set_xlim(0, 12) + ax.set_ylim(0, 6) + ax.set_aspect("auto") + ax.axis("off") + ax.set_title( + "Sliding Window — monitoring SLA (okno=5min, krok=1min)", + fontsize=FS_TITLE, + fontweight="bold", + pad=10, + ) + + # Time axis + ax.annotate( + "", + xy=(11.5, 0.5), + xytext=(0.5, 0.5), + arrowprops={"arrowstyle": "->", "lw": 1.5, "color": LN}, + ) + times = ["14:05", "14:06", "14:07", "14:08", "14:09"] + latencies = [120, 180, 340, 290, 150] + sla = 200 + + for i, (t, lat) in enumerate(zip(times, latencies, strict=False)): + x = 1.5 + i * 2.0 + ax.text(x, 0.1, t, fontsize=FS, ha="center") + + # Bar proportional to latency + bar_h = lat / 100.0 + is_breach = lat > sla + fill = "#F8D7DA" if is_breach else GRAY1 + edge = "#C62828" if is_breach else LN + draw_box( + ax, + x - 0.5, + 1.0, + 1.0, + bar_h, + "", + fill=fill, + rounded=False, + edgecolor=edge, + lw=1.5, + ) + ax.text( + x, + 1.0 + bar_h + 0.15, + f"{lat}ms", + fontsize=FS, + ha="center", + fontweight="bold", + color="#C62828" if is_breach else LN, + ) + + # Status + status = "ALERT!" if is_breach else "OK" + ax.text( + x, + 1.0 + bar_h + 0.55, + status, + fontsize=FS_SMALL, + ha="center", + fontweight="bold", + color="#C62828" if is_breach else "#2E7D32", + ) + + # SLA line + sla_y = 1.0 + sla / 100.0 + ax.plot([0.8, 11.2], [sla_y, sla_y], "k--", lw=1.5) + ax.text(11.3, sla_y, f"SLA={sla}ms", fontsize=FS, va="center", fontweight="bold") + + # Sliding window bracket + ax.annotate( + "", + xy=(1.0, 5.3), + xytext=(5.0, 5.3), + arrowprops={"arrowstyle": "<->", "lw": 1.5, "color": LN}, + ) + ax.text(3.0, 5.6, "okno = 5 min", fontsize=FS, ha="center", fontweight="bold") + + ax.annotate( + "", + xy=(3.0, 4.8), + xytext=(5.0, 4.8), + arrowprops={"arrowstyle": "<->", "lw": 1, "color": "#555"}, + ) + ax.text( + 4.0, + 4.4, + "krok = 1 min\n(nakładanie!)", + fontsize=FS_SMALL, + ha="center", + style="italic", + ) + + save_fig(fig, "q20_sliding_sla.png") + + +# ============================================================ +# 6. Session window — user sessions +# ============================================================ +def gen_session_users() -> None: + """Gen session users.""" + fig, axes = plt.subplots(2, 1, figsize=(10, 5)) + fig.suptitle( + "Session Window — sesje użytkowników (gap = 30 min)", + fontsize=FS_TITLE, + fontweight="bold", + ) + + # Anna: 2 sessions + ax = axes[0] + ax.set_xlim(0, 14) + ax.set_ylim(0, 3.5) + ax.set_aspect("auto") + ax.axis("off") + ax.set_title("Użytkownik Anna", fontsize=FS_LABEL, fontweight="bold") + + ax.annotate( + "", + xy=(13.5, 1.0), + xytext=(0.3, 1.0), + arrowprops={"arrowstyle": "->", "lw": 1.5, "color": LN}, + ) + + # Clicks cluster 1 + for x in [1.0, 1.8, 2.5, 3.2]: + ax.plot(x, 1.0, "ko", markersize=6) + # Clicks cluster 2 + for x in [9.0, 9.8, 10.5]: + ax.plot(x, 1.0, "ko", markersize=6) + + # Sessions + rect1 = mpatches.FancyBboxPatch( + (0.7, 1.5), + 2.8, + 1.2, + boxstyle="round,pad=0.1", + facecolor=GRAY1, + edgecolor=LN, + lw=1.5, + ) + ax.add_patch(rect1) + ax.text( + 2.1, + 2.1, + "Sesja 1\n4 kliknięcia, 12 min", + fontsize=FS, + ha="center", + fontweight="bold", + ) + + rect2 = mpatches.FancyBboxPatch( + (8.7, 1.5), + 2.1, + 1.2, + boxstyle="round,pad=0.1", + facecolor=GRAY3, + edgecolor=LN, + lw=1.5, + ) + ax.add_patch(rect2) + ax.text( + 9.75, + 2.1, + "Sesja 2\n3 kliknięcia, 8 min", + fontsize=FS, + ha="center", + fontweight="bold", + ) + + # Gap + ax.annotate( + "", + xy=(8.5, 0.5), + xytext=(3.8, 0.5), + arrowprops={"arrowstyle": "<->", "lw": 1.5, "color": LN}, + ) + ax.text( + 6.15, + 0.1, + "cisza 45 min > gap(30)", + fontsize=FS, + ha="center", + fontweight="bold", + style="italic", + ) + + # Bob: 1 session + ax = axes[1] + ax.set_xlim(0, 14) + ax.set_ylim(0, 3.5) + ax.set_aspect("auto") + ax.axis("off") + ax.set_title("Użytkownik Bob", fontsize=FS_LABEL, fontweight="bold") + + ax.annotate( + "", + xy=(13.5, 1.0), + xytext=(0.3, 1.0), + arrowprops={"arrowstyle": "->", "lw": 1.5, "color": LN}, + ) + + # Clicks spread evenly + bobs = [1.0, 2.5, 4.0, 5.5, 7.0, 8.5, 10.0] + for x in bobs: + ax.plot(x, 1.0, "ko", markersize=6) + + rect = mpatches.FancyBboxPatch( + (0.7, 1.5), + 9.6, + 1.2, + boxstyle="round,pad=0.1", + facecolor=GRAY1, + edgecolor=LN, + lw=2, + ) + ax.add_patch(rect) + ax.text( + 5.5, + 2.1, + "Sesja 1 (ciągła) — 7 kliknięć, każde < 30 min od poprzedniego", + fontsize=FS, + ha="center", + fontweight="bold", + ) + + fig.tight_layout(rect=[0, 0, 1, 0.92]) + save_fig(fig, "q20_session_users.png") diff --git a/python_pkg/praca_magisterska_video/generate_images/_q23_architectures.py b/python_pkg/praca_magisterska_video/generate_images/_q23_architectures.py new file mode 100644 index 0000000..01ae8ed --- /dev/null +++ b/python_pkg/praca_magisterska_video/generate_images/_q23_architectures.py @@ -0,0 +1,467 @@ +"""FCN and U-Net architecture diagram generators.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from _q23_common import ( + ACCENT, + ACCENT_LIGHT, + BLACK, + FS, + FS_SMALL, + FS_TINY, + FS_TITLE, + GRAY1, + GRAY2, + GRAY5, + GREEN_ACCENT, + RED_ACCENT, + _save_figure, + plt, +) +from matplotlib.patches import FancyBboxPatch + +if TYPE_CHECKING: + from matplotlib.axes import Axes + + +def generate_fcn() -> None: + """Generate fcn.""" + _fig, axes = plt.subplots(2, 1, figsize=(10, 7)) + + # --- Panel 1: FC vs Conv 1x1 --- + ax = axes[0] + ax.set_xlim(0, 20) + ax.set_ylim(0, 6) + ax.axis("off") + ax.set_title( + "FC (Fully Connected) vs Conv 1x1", fontsize=FS_TITLE, fontweight="bold" + ) + + # Classic CNN with FC + layer_info_fc = [ + (1.5, "Obraz\n224x224x3", 2.2, GRAY2), + (4.5, "Conv+Pool\n112x112x64", 1.8, GRAY2), + (7.5, "Conv+Pool\n7x7x512", 1.0, GRAY2), + (10, "Flatten\n25088", 0.5, ACCENT_LIGHT), + (12, "FC\n4096", 0.5, ACCENT_LIGHT), + (14, "FC\n1000", 0.3, ACCENT_LIGHT), + (16, '"Kot"', 0.3, "#FFCDD2"), + ] + + y_fc = 4.5 + for i, (x, label, w, color) in enumerate(layer_info_fc): + rect = FancyBboxPatch( + (x - w / 2, y_fc - 0.6), + w, + 1.2, + boxstyle="round,pad=0.05", + facecolor=color, + edgecolor=BLACK, + linewidth=0.8, + ) + ax.add_patch(rect) + ax.text(x, y_fc, label, ha="center", va="center", fontsize=FS_TINY) + if i < len(layer_info_fc) - 1: + next_x = layer_info_fc[i + 1][0] + ax.annotate( + "", + xy=(next_x - layer_info_fc[i + 1][2] / 2, y_fc), + xytext=(x + w / 2, y_fc), + arrowprops={"arrowstyle": "->", "color": GRAY5, "lw": 1}, + ) + + ax.text( + 0.3, y_fc, "CNN:", fontsize=FS, fontweight="bold", color=RED_ACCENT, va="center" + ) + ax.text( + 12, + y_fc + 1, + "PROBLEM: FC wymaga\nSTAŁEGO rozmiaru\n(np. 224x224)", + ha="center", + fontsize=FS_SMALL, + color=RED_ACCENT, + fontweight="bold", + bbox={ + "boxstyle": "round", + "facecolor": "#FFCDD2", + "edgecolor": RED_ACCENT, + "alpha": 0.3, + }, + ) + + # FCN with Conv 1x1 + layer_info_fcn = [ + (1.5, "Obraz\nHxWx3", 2.2, GRAY2), + (4.5, "Conv+Pool\nH/2 x W/2\nx64", 1.8, GRAY2), + (7.5, "Conv+Pool\nH/32 x W/32\nx512", 1.0, GRAY2), + (10.5, "Conv 1x1\nH/32 x W/32\nxC", 0.8, "#C8E6C9"), + (13.5, "Upsample\nHxWxC", 1.8, "#C8E6C9"), + (16.5, "Mapa\nsegmentacji", 1.5, "#C8E6C9"), + ] + + y_fcn = 1.5 + for i, (x, label, w, color) in enumerate(layer_info_fcn): + rect = FancyBboxPatch( + (x - w / 2, y_fcn - 0.7), + w, + 1.4, + boxstyle="round,pad=0.05", + facecolor=color, + edgecolor=BLACK, + linewidth=0.8, + ) + ax.add_patch(rect) + ax.text(x, y_fcn, label, ha="center", va="center", fontsize=FS_TINY) + if i < len(layer_info_fcn) - 1: + next_x = layer_info_fcn[i + 1][0] + ax.annotate( + "", + xy=(next_x - layer_info_fcn[i + 1][2] / 2, y_fcn), + xytext=(x + w / 2, y_fcn), + arrowprops={"arrowstyle": "->", "color": GRAY5, "lw": 1}, + ) + + ax.text( + 0.3, + y_fcn, + "FCN:", + fontsize=FS, + fontweight="bold", + color=GREEN_ACCENT, + va="center", + ) + ax.text( + 10.5, + y_fcn + 1.2, + "Conv 1x1:\nkażdy piksel\nosobno x wagi\n(jak FC ale\nzachowuje HxW)", + ha="center", + fontsize=FS_TINY, + color=GREEN_ACCENT, + bbox={ + "boxstyle": "round", + "facecolor": "#C8E6C9", + "edgecolor": GREEN_ACCENT, + "alpha": 0.3, + }, + ) + + # --- Panel 2: What FC and Conv do --- + ax = axes[1] + ax.set_xlim(0, 20) + ax.set_ylim(0, 6) + ax.axis("off") + ax.set_title( + "Co robi warstwa FC? Co robi konwolucja?", fontsize=FS_TITLE, fontweight="bold" + ) + + # FC explanation + rect = FancyBboxPatch( + (0.3, 3.2), + 9, + 2.5, + boxstyle="round,pad=0.15", + facecolor=ACCENT_LIGHT, + edgecolor=ACCENT, + linewidth=1, + ) + ax.add_patch(rect) + ax.text( + 4.8, 5.2, "Fully Connected (FC)", fontsize=FS, fontweight="bold", ha="center" + ) + ax.text( + 4.8, + 4.5, + "KAŻDY neuron połączony z KAŻDYM wejściem\n" + "25 088 wejść x 4 096 neuronów = ~103 MLN wag!\n" + "Traci informację GDZIE (przestrzenną)\n" + "Wymaga STAŁEGO rozmiaru wejścia", + fontsize=FS_TINY, + ha="center", + va="top", + ) + + # Conv explanation + rect = FancyBboxPatch( + (10.3, 3.2), + 9, + 2.5, + boxstyle="round,pad=0.15", + facecolor="#C8E6C9", + edgecolor=GREEN_ACCENT, + linewidth=1, + ) + ax.add_patch(rect) + ax.text(14.8, 5.2, "Konwolucja (Conv)", fontsize=FS, fontweight="bold", ha="center") + ax.text( + 14.8, + 4.5, + 'Filtr (np. 3x3) „jedzie" po obrazie\n' + "Te same wagi dla KAŻDEJ pozycji\n" + "Zachowuje informację GDZIE\n" + "Akceptuje DOWOLNY rozmiar wejścia", + fontsize=FS_TINY, + ha="center", + va="top", + ) + + # Conv 1x1 explanation + rect = FancyBboxPatch( + (3, 0.3), + 14, + 2.2, + boxstyle="round,pad=0.15", + facecolor=GRAY1, + edgecolor=BLACK, + linewidth=1, + ) + ax.add_patch(rect) + ax.text( + 10, + 2.1, + 'Conv 1x1 = „FC per piksel"', + fontsize=FS, + fontweight="bold", + ha="center", + ) + ax.text( + 10, + 1.5, + "Filtr 1x1: patrzy na JEDEN piksel, ale WSZYSTKIE kanały (512→C klas)\n" + "Działa jak FC ale zachowuje mapę HxW → każdy piksel osobno klasyfikowany\n" + "FCN: zamień FC na Conv1x1 → koniec z wymogiem stałego rozmiaru!", + fontsize=FS_TINY, + ha="center", + va="top", + ) + + _save_figure("q23_fc_vs_conv1x1.png") + + +def generate_unet() -> None: + """Generate unet.""" + _fig, ax = plt.subplots(1, 1, figsize=(10, 6)) + ax.set_xlim(-1, 21) + ax.set_ylim(-1, 12) + ax.axis("off") + ax.set_title( + "U-Net: architektura w kształcie litery U", + fontsize=FS_TITLE + 1, + fontweight="bold", + ) + + # Encoder layers (going DOWN-LEFT) + encoder_layers = [ + (2, 10, 2.5, 1.5, "572x572x1\n(wejście)", 64), + (2, 7.5, 2.2, 1.3, "284x284\nx64", 64), + (2, 5, 1.8, 1.1, "140x140\nx128", 128), + (2, 2.5, 1.5, 1.0, "68x68\nx256", 256), + ] + + # Bottleneck + bottleneck = (8, 0.5, 2.5, 1.2, "32x32x512\n(bottleneck)", 512) + + # Decoder layers (going UP-RIGHT) + decoder_layers = [ + (14, 2.5, 1.5, 1.0, "68x68\nx256", 256), + (14, 5, 1.8, 1.1, "140x140\nx128", 128), + (14, 7.5, 2.2, 1.3, "284x284\nx64", 64), + (14, 10, 2.5, 1.5, "572x572xC\n(mapa seg.)", "C"), + ] + + def draw_block( + ax: Axes, + x: float, + y: float, + w: float, + h: float, + label: str, + color: str, + ) -> None: + """Draw block.""" + rect = FancyBboxPatch( + (x - w / 2, y - h / 2), + w, + h, + boxstyle="round,pad=0.05", + facecolor=color, + edgecolor=BLACK, + linewidth=1.2, + ) + ax.add_patch(rect) + ax.text(x, y, label, ha="center", va="center", fontsize=FS_TINY) + + # Draw encoder + for x, y, w, h, label, _channels in encoder_layers: + draw_block(ax, x, y, w, h, label, ACCENT_LIGHT) + + # Draw arrows down (encoder) + for i in range(len(encoder_layers) - 1): + x1, y1 = encoder_layers[i][0], encoder_layers[i][1] - encoder_layers[i][3] / 2 + x2, y2 = ( + encoder_layers[i + 1][0], + encoder_layers[i + 1][1] + encoder_layers[i + 1][3] / 2, + ) + ax.annotate( + "", + xy=(x2, y2), + xytext=(x1, y1), + arrowprops={"arrowstyle": "->", "color": ACCENT, "lw": 2}, + ) + ax.text( + x1 - 1.7, + (y1 + y2) / 2, + "MaxPool\n2x2\n↓ zmniejsz", + fontsize=FS_TINY, + ha="center", + color=ACCENT, + fontweight="bold", + ) + + # Encoder to bottleneck + x1, y1 = encoder_layers[-1][0], encoder_layers[-1][1] - encoder_layers[-1][3] / 2 + draw_block( + ax, + bottleneck[0], + bottleneck[1], + bottleneck[2], + bottleneck[3], + bottleneck[4], + GRAY2, + ) + ax.annotate( + "", + xy=(bottleneck[0] - bottleneck[2] / 2, bottleneck[1] + bottleneck[3] / 2), + xytext=(x1, y1), + arrowprops={"arrowstyle": "->", "color": ACCENT, "lw": 2}, + ) + + # Bottleneck to decoder + ax.annotate( + "", + xy=( + decoder_layers[0][0] - decoder_layers[0][2] / 2, + decoder_layers[0][1] - decoder_layers[0][3] / 2, + ), + xytext=(bottleneck[0] + bottleneck[2] / 2, bottleneck[1] + bottleneck[3] / 2), + arrowprops={"arrowstyle": "->", "color": RED_ACCENT, "lw": 2}, + ) + + # Draw decoder + for x, y, w, h, label, channels in decoder_layers: + color = "#C8E6C9" if channels != "C" else "#A5D6A7" + draw_block(ax, x, y, w, h, label, color) + + # Draw arrows up (decoder) + for i in range(len(decoder_layers) - 1): + x1, y1 = decoder_layers[i][0], decoder_layers[i][1] + decoder_layers[i][3] / 2 + x2, y2 = ( + decoder_layers[i + 1][0], + decoder_layers[i + 1][1] - decoder_layers[i + 1][3] / 2, + ) + ax.annotate( + "", + xy=(x2, y2), + xytext=(x1, y1), + arrowprops={"arrowstyle": "->", "color": GREEN_ACCENT, "lw": 2}, + ) + ax.text( + x1 + 2, + (y1 + y2) / 2, + "UpConv\n2x2\n↑ zwiększ", + fontsize=FS_TINY, + ha="center", + color=GREEN_ACCENT, + fontweight="bold", + ) + + # Skip connections (horizontal arrows) + for i in range(len(encoder_layers)): + enc = encoder_layers[i] + dec = decoder_layers[len(decoder_layers) - 1 - i] + ax.annotate( + "", + xy=(dec[0] - dec[2] / 2, dec[1]), + xytext=(enc[0] + enc[2] / 2, enc[1]), + arrowprops={ + "arrowstyle": "->", + "color": GRAY5, + "lw": 1.5, + "linestyle": "dashed", + }, + ) + mid_x = (enc[0] + enc[2] / 2 + dec[0] - dec[2] / 2) / 2 + ax.text( + mid_x, + enc[1] + 0.6, + "skip\n(concat)", + fontsize=FS_TINY, + ha="center", + color=GRAY5, + fontweight="bold", + ) + + # Labels + ax.text( + 0, + 11.5, + "ENCODER\n(↓ zmniejsza)", + fontsize=FS, + fontweight="bold", + color=ACCENT, + ha="center", + ) + ax.text( + 17, + 11.5, + "DECODER\n(↑ zwiększa)", + fontsize=FS, + fontweight="bold", + color=GREEN_ACCENT, + ha="center", + ) + ax.text( + 8, + -0.8, + 'Kształt litery „U": encoder schodzi ↓ → bottleneck na dnie → decoder wraca ↑', + fontsize=FS_SMALL, + ha="center", + color=GRAY5, + fontweight="bold", + ) + + # Concatenation explanation + rect = FancyBboxPatch( + (17.5, 3), + 3, + 5, + boxstyle="round,pad=0.15", + facecolor=GRAY1, + edgecolor=GRAY5, + linewidth=1, + linestyle="--", + ) + ax.add_patch(rect) + ax.text( + 19, 7.5, "Concatenation:", fontsize=FS_SMALL, ha="center", fontweight="bold" + ) + ax.text( + 19, + 6.5, + "Encoder: 64 kanały\nDecoder: 64 kanały\n→ concat → 128 kanałów\n\n" + "Jak sklejenie\ndwóch stosów\nkart:", + fontsize=FS_TINY, + ha="center", + ) + ax.text( + 19, + 3.7, + "[enc₁|enc₂|...|dec₁|dec₂|...]", + fontsize=FS_TINY - 1, + ha="center", + fontweight="bold", + color=ACCENT, + ) + + _save_figure("q23_unet_arch.png") diff --git a/python_pkg/praca_magisterska_video/generate_images/_q23_common.py b/python_pkg/praca_magisterska_video/generate_images/_q23_common.py new file mode 100644 index 0000000..b425d71 --- /dev/null +++ b/python_pkg/praca_magisterska_video/generate_images/_q23_common.py @@ -0,0 +1,96 @@ +"""Common utilities and constants for Q23 diagram generation. + +A4-compatible, monochrome-friendly (grays + one accent), 300 DPI. +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import TYPE_CHECKING + +import matplotlib as mpl + +mpl.use("Agg") + +import matplotlib.pyplot as plt +import numpy as np + +if TYPE_CHECKING: + from matplotlib.axes import Axes + +_logger = logging.getLogger(__name__) + +rng = np.random.default_rng(42) + +DPI = 300 +OUTPUT_DIR = str(Path(__file__).resolve().parent / "img") +Path(OUTPUT_DIR).mkdir(parents=True, exist_ok=True) + +# Color palette — monochrome-friendly +BLACK = "#000000" +WHITE = "#FFFFFF" +GRAY1 = "#F5F5F5" +GRAY2 = "#E0E0E0" +GRAY3 = "#BDBDBD" +GRAY4 = "#9E9E9E" +GRAY5 = "#757575" +GRAY6 = "#424242" +ACCENT = "#4A90D9" # single blue accent for highlights +ACCENT_LIGHT = "#B3D4FC" +RED_ACCENT = "#D32F2F" +GREEN_ACCENT = "#388E3C" + +FS = 9 +FS_TITLE = 11 +FS_SMALL = 7 +FS_TINY = 6 + +_RIDGE_X = 5 +_VALLEY2_END = 9 +_DARK_PIXEL_THRESHOLD = 100 +_GRID_LAST_IDX = 3 +_HIGHLIGHT_START = 3 +_HIGHLIGHT_END = 5 +_BRIGHT_THRESHOLD = 170 +_OTSU_THRESHOLD = 128 + + +def _save_figure(name: str) -> None: + """Save current figure and log.""" + plt.tight_layout() + plt.savefig( + str(Path(OUTPUT_DIR) / name), + dpi=DPI, + bbox_inches="tight", + facecolor="white", + ) + plt.close() + _logger.info(" ✓ %s", name) + + +def _render_text_lines( + ax: Axes, + lines: list[tuple[str, int, str, str]], + *, + x_pos: float = 0.5, + start_y: float, + y_step: float = 0.5, + y_empty_step: float = 0.2, +) -> None: + """Render a list of styled text lines on an axis.""" + y = start_y + for txt, size, color, weight in lines: + if txt == "": + y -= y_empty_step + continue + ax.text( + x_pos, + y, + txt, + fontsize=size, + color=color, + fontweight=weight, + va="top", + ) + y -= y_step diff --git a/python_pkg/praca_magisterska_video/generate_images/_q23_diy_unet.py b/python_pkg/praca_magisterska_video/generate_images/_q23_diy_unet.py new file mode 100644 index 0000000..f6c69d0 --- /dev/null +++ b/python_pkg/praca_magisterska_video/generate_images/_q23_diy_unet.py @@ -0,0 +1,251 @@ +"""DIY U-Net step-by-step diagram generator.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from _q23_common import ( + ACCENT, + ACCENT_LIGHT, + BLACK, + FS, + FS_SMALL, + FS_TINY, + GRAY1, + GRAY3, + GRAY5, + GREEN_ACCENT, + WHITE, + _save_figure, + np, + plt, + rng, +) +from matplotlib.patches import FancyBboxPatch + +if TYPE_CHECKING: + from matplotlib.axes import Axes + + +def _draw_unet_layer_stack( + ax: Axes, + layer_sizes: list[tuple[int, int]], + *, + face_color: str, + edge_color: str, + arrow_color: str, + arrow_label: str, + add_skip: bool = False, +) -> None: + """Draw encoder or decoder layer stack for DIY U-Net.""" + ax.set_xlim(0, 10) + ax.set_ylim(0, 10) + ax.axis("off") + + y_pos = 8.5 + for i, (s, c) in enumerate(layer_sizes): + w = s / 64 * 4 + h = 0.8 + rect = FancyBboxPatch( + (5 - w / 2, y_pos), + w, + h, + boxstyle="round,pad=0.05", + facecolor=face_color, + edgecolor=edge_color, + linewidth=1, + ) + ax.add_patch(rect) + label = f"{s}x{s}x{c}" + if add_skip and i < len(layer_sizes) - 1: + label += " + skip!" + ax.text( + 5, + y_pos + h / 2, + label, + ha="center", + va="center", + fontsize=FS_SMALL, + fontweight="bold", + ) + if i < len(layer_sizes) - 1: + ax.annotate( + "", + xy=(5, y_pos - 0.3), + xytext=(5, y_pos), + arrowprops={ + "arrowstyle": "->", + "color": arrow_color, + "lw": 1.5, + }, + ) + ax.text( + 7, + y_pos - 0.15, + arrow_label, + fontsize=FS_TINY, + color=arrow_color, + ) + y_pos -= 2.2 + + +def _draw_unet_pseudocode(ax: Axes) -> None: + """Draw panel 6: U-Net pseudocode.""" + ax.set_xlim(0, 10) + ax.set_ylim(0, 10) + ax.axis("off") + ax.set_title("Pseudokod U-Net", fontsize=FS, fontweight="bold") + + code_lines = [ + "# ENCODER", + "e1 = conv_block(input, 64) # 64x64", + "e2 = conv_block(pool(e1), 128) # 32x32", + "e3 = conv_block(pool(e2), 256) # 16x16", + "", + "# BOTTLENECK", + "b = conv_block(pool(e3), 512) # 8x8", + "", + "# DECODER + SKIP", + "d3 = conv_block(concat(", + " upconv(b), e3), 256) # 16x16", + "d2 = conv_block(concat(", + " upconv(d3), e2), 128) # 32x32", + "d1 = conv_block(concat(", + " upconv(d2), e1), 64) # 64x64", + "", + "output = conv_1x1(d1, n_classes)", + ] + for i, line in enumerate(code_lines): + txt_color = ( + ACCENT + if "concat" in line + else (GREEN_ACCENT if "output" in line else BLACK) + ) + ax.text( + 0.3, + 9.5 - i * 0.55, + line, + fontsize=FS_TINY, + fontfamily="monospace", + color=txt_color, + ) + + +def generate_diy_unet() -> None: + """Generate diy unet.""" + fig, axes = plt.subplots(2, 3, figsize=(11, 7)) + + size = 64 + + # Create synthetic image with two regions + img = np.ones((size, size, 3), dtype=np.uint8) * 200 # bright bg + # Dark region (object 1) + yy, xx = np.mgrid[:size, :size] + mask1 = ((xx - 20) ** 2 + (yy - 30) ** 2) < 12**2 + img[mask1] = [60, 60, 60] + # Medium region (object 2) + mask2 = ((xx - 45) ** 2 + (yy - 25) ** 2) < 8**2 + img[mask2] = [120, 120, 120] + + gt = np.zeros((size, size), dtype=np.uint8) + gt[mask1] = 1 # class 1 + gt[mask2] = 2 # class 2 + + # --- Panel 1: Input image --- + ax = axes[0, 0] + ax.imshow(img) + ax.set_title("Krok 1: obraz RGB\n64x64x3", fontsize=FS, fontweight="bold") + ax.axis("off") + + # --- Panel 2: Encoder shrinks --- + ax = axes[0, 1] + ax.set_title("Krok 2: Encoder ZMNIEJSZA", fontsize=FS, fontweight="bold") + _draw_unet_layer_stack( + ax, + [(64, 3), (32, 64), (16, 128), (8, 256)], + face_color=ACCENT_LIGHT, + edge_color=ACCENT, + arrow_color=ACCENT, + arrow_label="Conv+Pool", + ) + ax.text( + 5, + 0.3, + "Wyciąga cechy:\nkrawędzie → tekstury → obiekty", + ha="center", + fontsize=FS_TINY, + color=GRAY5, + ) + + # --- Panel 3: Bottleneck --- + ax = axes[0, 2] + # Show feature maps at bottleneck (abstract) + ax.set_xlim(0, 10) + ax.set_ylim(0, 10) + ax.axis("off") + ax.set_title( + "Krok 3: Bottleneck\n(najbardziej abstrakcyjne cechy)", + fontsize=FS, + fontweight="bold", + ) + + # Show small abstract feature maps + for k in range(4): + small = rng.random((4, 4)) + ax_inset = fig.add_axes( + [0.68 + (k % 2) * 0.08, 0.72 - (k // 2) * 0.1, 0.06, 0.06] + ) + ax_inset.imshow(small, cmap="gray") + ax_inset.axis("off") + + ax.text( + 5, + 5, + '8x8x256\n\nMałe mapy, ale DUŻO kanałów\nKażdy kanał = jedna „cecha"\n' + '(np. kanał 42 = „wykrył koło"\n kanał 78 = „wykrył krawędź")\n\n' + "Wie CO jest na obrazie\nale nie wie GDZIE dokładnie", + ha="center", + va="center", + fontsize=FS_SMALL, + bbox={"boxstyle": "round", "facecolor": GRAY1, "edgecolor": GRAY3}, + ) + + # --- Panel 4: Decoder enlarges --- + ax = axes[1, 0] + ax.set_title( + "Krok 4: Decoder ZWIĘKSZA\n(+ skip connections!)", + fontsize=FS, + fontweight="bold", + ) + _draw_unet_layer_stack( + ax, + [(8, 256), (16, 128), (32, 64), (64, 3)], + face_color="#C8E6C9", + edge_color=GREEN_ACCENT, + arrow_color=GREEN_ACCENT, + arrow_label="UpConv+Concat", + add_skip=True, + ) + ax.text( + 5, + 0.3, + "Odtwarza rozdzielczość:\nskip → przywraca krawędzie", + ha="center", + fontsize=FS_TINY, + color=GRAY5, + ) + + # --- Panel 5: Output segmentation map --- + ax = axes[1, 1] + cmap = plt.cm.colors.ListedColormap([WHITE, ACCENT_LIGHT, "#FFCDD2"]) + ax.imshow(gt, cmap=cmap, interpolation="nearest") + ax.set_title( + "Krok 5: mapa segmentacji\n64x64 (3 klasy)", fontsize=FS, fontweight="bold" + ) + ax.axis("off") + ax.text(20, -3, "Tło=0, obiekt A=1, obiekt B=2", fontsize=FS_TINY, ha="center") + + # --- Panel 6: Summary pseudocode --- + _draw_unet_pseudocode(axes[1, 2]) + + _save_figure("q23_diy_unet.png") diff --git a/python_pkg/praca_magisterska_video/generate_images/_q23_mean_shift_ncuts.py b/python_pkg/praca_magisterska_video/generate_images/_q23_mean_shift_ncuts.py new file mode 100644 index 0000000..912113c --- /dev/null +++ b/python_pkg/praca_magisterska_video/generate_images/_q23_mean_shift_ncuts.py @@ -0,0 +1,380 @@ +"""Mean shift and normalized cuts diagram generators.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from _q23_common import ( + _DARK_PIXEL_THRESHOLD, + _GRID_LAST_IDX, + ACCENT, + ACCENT_LIGHT, + BLACK, + FS, + FS_SMALL, + FS_TINY, + FS_TITLE, + GRAY1, + GRAY3, + GRAY4, + GRAY5, + GRAY6, + GREEN_ACCENT, + RED_ACCENT, + _render_text_lines, + _save_figure, + np, + plt, + rng, +) +from matplotlib import patches + +if TYPE_CHECKING: + from matplotlib.axes import Axes + + +def generate_mean_shift() -> None: + """Generate mean shift.""" + _fig, axes = plt.subplots(1, 3, figsize=(11, 4)) + + # --- Panel 1: Feature space concept --- + ax = axes[0] + # Three clusters in 2D feature space (brightness, x-position) + c1x = rng.normal(2, 0.5, 40) + c1y = rng.normal(2, 0.5, 40) + c2x = rng.normal(6, 0.6, 35) + c2y = rng.normal(7, 0.5, 35) + c3x = rng.normal(8, 0.4, 25) + c3y = rng.normal(3, 0.6, 25) + + ax.scatter(c1x, c1y, c=GRAY4, s=15, alpha=0.7, zorder=3) + ax.scatter(c2x, c2y, c=GRAY4, s=15, alpha=0.7, zorder=3) + ax.scatter(c3x, c3y, c=GRAY4, s=15, alpha=0.7, zorder=3) + + # Label peaks + ax.scatter([2], [2], c=RED_ACCENT, s=80, marker="*", zorder=5, label="Max gęstości") + ax.scatter([6], [7], c=RED_ACCENT, s=80, marker="*", zorder=5) + ax.scatter([8], [3], c=RED_ACCENT, s=80, marker="*", zorder=5) + + ax.set_xlabel("Cecha 1: jasność", fontsize=FS) + ax.set_ylabel("Cecha 2: pozycja x", fontsize=FS) + ax.set_title("Przestrzeń cech", fontsize=FS_TITLE, fontweight="bold") + for lx, ly, ltxt in [ + (2, 0.3, "Klaster 1\n(ciemne, lewo)"), + (6, 5.3, "Klaster 2\n(jasne, prawo)"), + (8, 1.3, "Klaster 3\n(jasne, dół)"), + ]: + ax.text(lx, ly, ltxt, ha="center", fontsize=FS_TINY, color=GRAY6) + ax.legend(fontsize=FS_SMALL, loc="upper left") + + # --- Panel 2: Kernel/window moving --- + ax = axes[1] + ax.scatter(c1x, c1y, c=ACCENT_LIGHT, s=15, alpha=0.7, zorder=3) + ax.scatter(c2x, c2y, c=GRAY3, s=15, alpha=0.7, zorder=3) + ax.scatter(c3x, c3y, c=GRAY3, s=15, alpha=0.7, zorder=3) + + # Show kernel movement + path_x = [4.5, 3.8, 3.0, 2.3, 2.05] + path_y = [4.0, 3.3, 2.7, 2.2, 2.03] + + for i, (px, py) in enumerate(zip(path_x, path_y, strict=False)): + alpha = 0.3 + 0.15 * i + circle = plt.Circle( + (px, py), + 1.2, + fill=False, + edgecolor=ACCENT, + linewidth=1.5, + linestyle="--" if i < len(path_x) - 1 else "-", + alpha=alpha, + ) + ax.add_patch(circle) + if i < len(path_x) - 1: + ax.annotate( + "", + xy=(path_x[i + 1], path_y[i + 1]), + xytext=(px, py), + arrowprops={"arrowstyle": "->", "color": RED_ACCENT, "lw": 1.5}, + ) + + ax.scatter([path_x[0]], [path_y[0]], c=ACCENT, s=50, marker="o", zorder=5) + ax.scatter([path_x[-1]], [path_y[-1]], c=RED_ACCENT, s=80, marker="*", zorder=5) + + ax.text( + 4.5, 5.2, "Start: losowy\npiksel", fontsize=FS_SMALL, ha="center", color=ACCENT + ) + ax.text( + 2.05, + 0.5, + "Koniec: max\ngęstości", + fontsize=FS_SMALL, + ha="center", + color=RED_ACCENT, + fontweight="bold", + ) + ax.text( + 7, + 8, + "Okno (jądro)\nprzesuwa się\ndo skupiska", + fontsize=FS_SMALL, + ha="center", + color=GRAY6, + bbox={"boxstyle": "round", "facecolor": GRAY1, "edgecolor": GRAY3}, + ) + + ax.set_xlabel("Cecha 1", fontsize=FS) + ax.set_ylabel("Cecha 2", fontsize=FS) + ax.set_title("Jądro → max gęstości", fontsize=FS_TITLE, fontweight="bold") + ax.set_xlim(0, 10) + ax.set_ylim(0, 9) + + # --- Panel 3: Why no K parameter --- + ax = axes[2] + ax.set_xlim(0, 10) + ax.set_ylim(0, 10) + ax.axis("off") + ax.set_title("Dlaczego bez K?", fontsize=FS_TITLE, fontweight="bold") + + lines = [ + ("K-means wymaga:", FS, RED_ACCENT, "bold"), + (' „Podaj K=3 klastry"', FS_SMALL, "black", "normal"), + (" Problem: skąd wiesz ile klastrów?", FS_SMALL, GRAY5, "normal"), + ("", 0, "", ""), + ("Mean Shift NIE wymaga K:", FS, GREEN_ACCENT, "bold"), + (" Każdy piksel startuje → toczy się", FS_SMALL, "black", "normal"), + (" → trafia do najbliższego szczytu", FS_SMALL, "black", "normal"), + (" → ile szczytów = tyle segmentów", FS_SMALL, "black", "normal"), + (" → automatycznie!", FS_SMALL, GREEN_ACCENT, "bold"), + ("", 0, "", ""), + ("Parametr: bandwidth (szerokość okna)", FS, "black", "bold"), + (" Duże okno → mało segmentów", FS_SMALL, "black", "normal"), + (" Małe okno → dużo segmentów", FS_SMALL, "black", "normal"), + ("", 0, "", ""), + ("Okno = jądro (kernel):", FS, "black", "bold"), + (" Koło o promieniu h wokół punktu.", FS_SMALL, "black", "normal"), + (" Oblicz średnią pikseli W oknie.", FS_SMALL, "black", "normal"), + (" Przesuń okno na tę średnią.", FS_SMALL, "black", "normal"), + (" Powtórz aż się zatrzyma.", FS_SMALL, "black", "normal"), + ] + _render_text_lines(ax, lines, start_y=9.0) + + _save_figure("q23_mean_shift.png") + + +def _draw_ncuts_pixel_grid( + ax: Axes, + pixel_vals: np.ndarray, +) -> None: + """Draw 4x4 pixel grid with value labels and edge weights.""" + for i in range(4): + for j in range(4): + v = pixel_vals[i, j] + gray_val = v / 255.0 + str(gray_val) + rect = patches.Rectangle( + (j - 0.4, 3 - i - 0.4), + 0.8, + 0.8, + facecolor=(gray_val, gray_val, gray_val), + edgecolor=BLACK, + linewidth=0.8, + ) + ax.add_patch(rect) + text_color = "white" if v < _DARK_PIXEL_THRESHOLD else "black" + ax.text( + j, + 3 - i, + str(v), + ha="center", + va="center", + fontsize=FS_SMALL, + color=text_color, + fontweight="bold", + ) + + +def _draw_ncuts_edges( + ax: Axes, + pixel_vals: np.ndarray, +) -> None: + """Draw weighted edges between adjacent pixels.""" + for i in range(4): + for j in range(4): + if j < _GRID_LAST_IDX: + similarity = max( + 0, + 1 - abs(pixel_vals[i, j] - pixel_vals[i, j + 1]) / 255, + ) + lw = similarity * 2.5 + 0.3 + alpha = similarity * 0.8 + 0.2 + ax.plot( + [j + 0.4, j + 0.6], + [3 - i, 3 - i], + color=GRAY5, + linewidth=lw, + alpha=alpha, + ) + if i < _GRID_LAST_IDX: + similarity = max( + 0, + 1 - abs(pixel_vals[i, j] - pixel_vals[i + 1, j]) / 255, + ) + lw = similarity * 2.5 + 0.3 + alpha = similarity * 0.8 + 0.2 + ax.plot( + [j, j], + [3 - i - 0.4, 3 - i - 0.6], + color=GRAY5, + linewidth=lw, + alpha=alpha, + ) + + +def generate_normalized_cuts() -> None: + """Generate normalized cuts.""" + _fig, axes = plt.subplots(1, 3, figsize=(11, 4)) + + # --- Panel 1: Image as graph --- + ax = axes[0] + ax.set_xlim(-0.5, 4.5) + ax.set_ylim(-0.5, 4.5) + ax.set_aspect("equal") + ax.set_title("Obraz → graf", fontsize=FS_TITLE, fontweight="bold") + + pixel_vals = np.array( + [ + [30, 35, 180, 190], + [40, 30, 185, 200], + [170, 180, 40, 35], + [190, 175, 30, 45], + ] + ) + _draw_ncuts_pixel_grid(ax, pixel_vals) + _draw_ncuts_edges(ax, pixel_vals) + + ax.text( + 2, + -0.8, + "Grube linie = duże podobieństwo\n(silna krawędź grafu)", + ha="center", + fontsize=FS_TINY, + color=GRAY5, + ) + ax.axis("off") + + # --- Panel 2: Cut concept --- + ax = axes[1] + ax.set_xlim(0, 10) + ax.set_ylim(0, 10) + ax.axis("off") + ax.set_title("Cięcie grafu (graph cut)", fontsize=FS_TITLE, fontweight="bold") + + # Draw two groups of nodes + # Group A (dark pixels) + positions_a = [(2, 7), (3, 8), (2, 5), (3, 6)] + positions_b = [(7, 7), (8, 8), (7, 5), (8, 6)] + + # Intra-group edges (thick = similar) + for i, (x1, y1) in enumerate(positions_a): + for x2, y2 in positions_a[i + 1 :]: + ax.plot([x1, x2], [y1, y2], color=ACCENT, linewidth=2, alpha=0.5) + for i, (x1, y1) in enumerate(positions_b): + for x2, y2 in positions_b[i + 1 :]: + ax.plot([x1, x2], [y1, y2], color=RED_ACCENT, linewidth=2, alpha=0.5) + + # Inter-group edges (thin = dissimilar) — these get cut + cut_edges = [((3, 8), (7, 7)), ((3, 6), (7, 5)), ((2, 5), (7, 5))] + for (x1, y1), (x2, y2) in cut_edges: + ax.plot([x1, x2], [y1, y2], color=GRAY4, linewidth=0.8, linestyle="--") + + # Draw nodes + for x, y in positions_a: + ax.scatter(x, y, c=ACCENT, s=120, zorder=5, edgecolors=BLACK, linewidth=0.8) + for x, y in positions_b: + ax.scatter(x, y, c="#FFCDD2", s=120, zorder=5, edgecolors=BLACK, linewidth=0.8) + + # Cut line + ax.plot( + [5, 5], [3.5, 9.5], color=RED_ACCENT, linewidth=2.5, linestyle="-", zorder=4 + ) + ax.text( + 5, 9.8, "CIĘCIE", ha="center", fontsize=FS, fontweight="bold", color=RED_ACCENT + ) + + ax.text( + 2.5, + 3.8, + "Segment A\n(ciemne piksele)", + ha="center", + fontsize=FS_SMALL, + color=ACCENT, + ) + ax.text( + 7.5, + 3.8, + "Segment B\n(jasne piksele)", + ha="center", + fontsize=FS_SMALL, + color=RED_ACCENT, + ) + + # Formula + ax.text( + 5, + 1.8, + "Ncut(A,B) = cut(A,B)/assoc(A,V)\n + cut(A,B)/assoc(B,V)", + ha="center", + fontsize=FS_SMALL, + fontweight="bold", + bbox={"boxstyle": "round", "facecolor": GRAY1, "edgecolor": GRAY3}, + ) + ax.text( + 5, + 0.5, + "Minimalizuj Ncut → tnij SŁABE krawędzie\nzachowuj SILNE (wewnątrz grupy)", + ha="center", + fontsize=FS_TINY, + color=GRAY5, + ) + + # --- Panel 3: Algorithm summary --- + ax = axes[2] + ax.set_xlim(0, 10) + ax.set_ylim(0, 10) + ax.axis("off") + ax.set_title("Algorytm Normalized Cuts", fontsize=FS_TITLE, fontweight="bold") + + steps = [ + ( + "1. Zbuduj graf", + "Piksele = węzły\nKrawędzie = podobieństwo" + " sąsiadów\n(kolor, jasność, odległość)", + ), + ( + "2. Macierz podobieństwa W", + "W[i,j] = exp(-|kolori - kolorj|² / σ²)" + "\n→ im podobniejsze, tym wyższa waga", + ), + ("3. Macierz stopni D", "D[i,i] = Σ W[i,j]\n(suma wszystkich wag z węzła i)"), + ("4. Rozwiąż problem własny", "(D-W)·y = λ·D·y\n→ drugi najm. wektor własny y"), + ("5. Podziel wg y", "y[i] > 0 → segment A\ny[i] ≤ 0 → segment B"), + ] + + y = 9.5 + for title, desc in steps: + ax.text(0.5, y, title, fontsize=FS, fontweight="bold", va="top") + y -= 0.4 + ax.text(0.8, y, desc, fontsize=FS_TINY, va="top", color=GRAY6) + y -= 1.2 + + ax.text( + 5, + 0.3, + "Złożoność: O(n³) — wymaga eigen decomposition!", + ha="center", + fontsize=FS_SMALL, + fontweight="bold", + color=RED_ACCENT, + ) + + _save_figure("q23_normalized_cuts.png") diff --git a/python_pkg/praca_magisterska_video/generate_images/_q23_mnemonics.py b/python_pkg/praca_magisterska_video/generate_images/_q23_mnemonics.py new file mode 100644 index 0000000..9f340a2 --- /dev/null +++ b/python_pkg/praca_magisterska_video/generate_images/_q23_mnemonics.py @@ -0,0 +1,327 @@ +"""Mnemonic summary diagram generator.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from _q23_common import ( + ACCENT, + ACCENT_LIGHT, + BLACK, + FS, + FS_SMALL, + FS_TINY, + FS_TITLE, + GRAY1, + GRAY5, + GRAY6, + GREEN_ACCENT, + RED_ACCENT, + _save_figure, + plt, +) +from matplotlib.patches import FancyBboxPatch + +if TYPE_CHECKING: + from matplotlib.axes import Axes + + +def generate_mnemonics() -> None: + """Generate mnemonics.""" + _fig, ax = plt.subplots(1, 1, figsize=(10, 8)) + ax.set_xlim(0, 20) + ax.set_ylim(0, 16) + ax.axis("off") + ax.set_title( + "Mnemoniki — segmentacja obrazu", fontsize=FS_TITLE + 2, fontweight="bold" + ) + + def draw_card( + ax: Axes, + x: float, + y: float, + w: float, + h: float, + title: str, + mnemonic: str, + color: str, + detail: str = "", + ) -> None: + """Draw card.""" + rect = FancyBboxPatch( + (x, y), + w, + h, + boxstyle="round,pad=0.15", + facecolor=color, + edgecolor=BLACK, + linewidth=1, + ) + ax.add_patch(rect) + ax.text( + x + w / 2, + y + h - 0.3, + title, + ha="center", + va="top", + fontsize=FS, + fontweight="bold", + ) + ax.text( + x + w / 2, + y + h / 2 - 0.1, + mnemonic, + ha="center", + va="center", + fontsize=FS_SMALL, + fontstyle="italic", + color=GRAY6, + ) + if detail: + ax.text( + x + w / 2, + y + 0.4, + detail, + ha="center", + va="bottom", + fontsize=FS_TINY, + color=GRAY5, + ) + + # Title: STRATEGIE KLASYCZNE + ax.text( + 5, + 15.5, + "STRATEGIE KLASYCZNE", + fontsize=FS_TITLE, + fontweight="bold", + color=ACCENT, + ha="center", + ) + + cards_classic = [ + ( + 0.2, + 12.5, + 4.5, + 2.5, + "Thresholding", + '„PRÓG na bramce"\nPrzepuszcza > T,\nblokuje ≤ T', + ACCENT_LIGHT, + "jasne=1, ciemne=0", + ), + ( + 5, + 12.5, + 4.5, + 2.5, + "Otsu", + '„AUTO-bramkarz"\nSam dobiera próg\nmin σ² wewnątrz', + ACCENT_LIGHT, + "histogram bimodalny", + ), + ( + 0.2, + 9.5, + 4.5, + 2.5, + "Region Growing", + '„PLAMA rozlana"\nSeed → BFS po\npodobnych sąsiadach', + ACCENT_LIGHT, + "jak atrament na papierze", + ), + ( + 5, + 9.5, + 4.5, + 2.5, + "Watershed", + '„ZALEWANIE terenu"\nDoliny=obiekty\nGranie=granice', + ACCENT_LIGHT, + "woda + geography", + ), + ( + 0.2, + 6.5, + 4.5, + 2.5, + "Mean Shift", + '„KULKI toczą się"\nKażda → max gęstości\nBez K!', + ACCENT_LIGHT, + "bandwidth = okno", + ), + ( + 5, + 6.5, + 4.5, + 2.5, + "Normalized Cuts", + '„CIĘCIE sznurków"\nGraf: tnij słabe\nkrawędzie (O(n³)!)', + ACCENT_LIGHT, + "eigenvector problem", + ), + ] + + for args in cards_classic: + draw_card(ax, *args) + + # Title: SIECI NEURONOWE + ax.text( + 15, + 15.5, + "SIECI NEURONOWE", + fontsize=FS_TITLE, + fontweight="bold", + color=GREEN_ACCENT, + ha="center", + ) + + cards_nn = [ + ( + 10.5, + 12.5, + 4.5, + 2.5, + "FCN (2015)", + '„FC → Conv 1x1"\nPierwsza end-to-end\nDowolny rozmiar', + "#C8E6C9", + "skip connections", + ), + ( + 15.3, + 12.5, + 4.5, + 2.5, + "U-Net (2015)", + '„Litera U"\nEncoder↓ Decoder↑\nSkip = concat', + "#C8E6C9", + "medycyna, małe dane", + ), + ( + 10.5, + 9.5, + 4.5, + 2.5, + "DeepLab v3+", + '„DZIURY w filtrze"\nAtrous conv (rate)\nASPP multi-scale', + "#C8E6C9", + "à trous = z dziurami", + ), + ( + 15.3, + 9.5, + 4.5, + 2.5, + "Transformer", + '„WSZYSCY ze\nWSZYSTKIMI"\nSelf-attention O(n²)', + "#C8E6C9", + "SegFormer, Mask2Former", + ), + ] + + for args in cards_nn: + draw_card(ax, *args) + + # Metryki + ax.text( + 10, + 8.3, + "METRYKI I LOSS", + fontsize=FS_TITLE, + fontweight="bold", + color=RED_ACCENT, + ha="center", + ) + + cards_metrics = [ + ( + 10.5, + 6.5, + 4.5, + 1.6, + "mIoU", + '„Nakładka / Suma"\nIoU = A∩B / A\u222aB', + "#FFCDD2", + "", + ), + ( + 15.3, + 6.5, + 4.5, + 1.6, + "Dice / Focal", + '„Dice=2·nakładka"\nFocal=trudne px', + "#FFCDD2", + "", + ), + ] + + for args in cards_metrics: + draw_card(ax, *args) + + # Master mnemonic at bottom + rect = FancyBboxPatch( + (1, 0.3), + 18, + 5.5, + boxstyle="round,pad=0.2", + facecolor=GRAY1, + edgecolor=BLACK, + linewidth=1.5, + ) + ax.add_patch(rect) + ax.text( + 10, + 5.3, + "SUPER-MNEMONIK: kolejność algorytmów segmentacji", + ha="center", + fontsize=FS, + fontweight="bold", + ) + ax.text( + 10, + 4.5, + '„TORW-MN FUD-T"', + ha="center", + fontsize=FS_TITLE + 2, + fontweight="bold", + color=RED_ACCENT, + ) + ax.text( + 10, + 3.5, + "Klasyczne: Thresholding → Otsu → Region" + " growing → Watershed → Mean shift → Norm. cuts", + ha="center", + fontsize=FS_SMALL, + ) + ax.text( + 10, + 2.8, + "Neuronowe: FCN → U-Net → DeepLab → Transformer", + ha="center", + fontsize=FS_SMALL, + ) + ax.text( + 10, + 1.8, + "„Turyści Oglądają Rzekę, Wodospad," + " Morze, Nurt — Fotografują Uroczy" + ' Dwór Tajemnic"', + ha="center", + fontsize=FS_SMALL, + fontstyle="italic", + color=ACCENT, + ) + ax.text( + 10, + 1.0, + "Klasyczne: proste→auto→BFS→flood→" + "gęstość→graf | Neuronowe:" + " FC→U-skip→dilated→attention", + ha="center", + fontsize=FS_TINY, + color=GRAY5, + ) + + _save_figure("q23_mnemonics.png") diff --git a/python_pkg/praca_magisterska_video/generate_images/_q23_nn_basics.py b/python_pkg/praca_magisterska_video/generate_images/_q23_nn_basics.py new file mode 100644 index 0000000..1d737ea --- /dev/null +++ b/python_pkg/praca_magisterska_video/generate_images/_q23_nn_basics.py @@ -0,0 +1,293 @@ +"""ReLU and dot product diagram generators.""" + +from __future__ import annotations + +from _q23_common import ( + ACCENT, + ACCENT_LIGHT, + BLACK, + FS, + FS_SMALL, + FS_TINY, + FS_TITLE, + GRAY1, + GRAY2, + GRAY3, + GRAY5, + GREEN_ACCENT, + RED_ACCENT, + _save_figure, + np, + plt, +) +from matplotlib import patches + + +def generate_relu() -> None: + """Generate relu.""" + _fig, axes = plt.subplots(1, 2, figsize=(8, 3.5)) + + # --- Panel 1: ReLU plot --- + ax = axes[0] + x = np.linspace(-5, 5, 200) + relu = np.maximum(0, x) + ax.plot(x, relu, color=ACCENT, linewidth=2.5, label="ReLU(x) = max(0, x)") + ax.axhline(y=0, color=GRAY3, linewidth=0.5) + ax.axvline(x=0, color=GRAY3, linewidth=0.5) + ax.fill_between(x[x < 0], 0, 0, color=RED_ACCENT, alpha=0.1) + ax.fill_between(x[x >= 0], 0, relu[x >= 0], color=ACCENT, alpha=0.1) + + # Annotations + ax.annotate( + 'x < 0 → output = 0\n(neuron „wyłączony")', + xy=(-3, 0), + fontsize=FS_SMALL, + ha="center", + va="bottom", + color=RED_ACCENT, + arrowprops={"arrowstyle": "->", "color": RED_ACCENT}, + xytext=(-3, 2), + ) + ax.annotate( + 'x ≥ 0 → output = x\n(neuron „włączony")', + xy=(3, 3), + fontsize=FS_SMALL, + ha="center", + va="bottom", + color=ACCENT, + arrowprops={"arrowstyle": "->", "color": ACCENT}, + xytext=(3, 4.5), + ) + ax.scatter([0], [0], c=BLACK, s=40, zorder=5) + ax.text(0.3, -0.5, "(0,0)", fontsize=FS_SMALL, color=GRAY5) + ax.set_xlabel("x (wejście neuronu)", fontsize=FS) + ax.set_ylabel("ReLU(x)", fontsize=FS) + ax.set_title("ReLU — Rectified Linear Unit", fontsize=FS_TITLE, fontweight="bold") + ax.legend(fontsize=FS_SMALL, loc="upper left") + ax.set_ylim(-1, 6) + ax.grid(visible=True, alpha=0.2) + + # --- Panel 2: Why ReLU --- + ax = axes[1] + ax.set_xlim(0, 10) + ax.set_ylim(0, 10) + ax.axis("off") + ax.set_title("Dlaczego ReLU?", fontsize=FS_TITLE, fontweight="bold") + + y = 9.0 + lines = [ + ("Neuron oblicza:", FS, BLACK, "bold"), + (" z = w₁·x₁ + w₂·x₂ + ... + bias", FS_SMALL, BLACK, "normal"), + (" output = ReLU(z) = max(0, z)", FS_SMALL, ACCENT, "bold"), + ("", 0, "", ""), + ("Przykład:", FS, BLACK, "bold"), + (" wagi: w₁=0.5, w₂=-0.3, bias=0.1", FS_SMALL, BLACK, "normal"), + (" wejścia: x₁=2.0, x₂=4.0", FS_SMALL, BLACK, "normal"), + (" z = 0.5·2 + (-0.3)·4 + 0.1 = -0.1", FS_SMALL, BLACK, "normal"), + (" ReLU(-0.1) = max(0, -0.1) = 0", FS_SMALL, RED_ACCENT, "bold"), + (" → neuron milczy (wejście nieistotne)", FS_SMALL, GRAY5, "normal"), + ("", 0, "", ""), + ("Gdyby z = 2.3:", FS, BLACK, "bold"), + (" ReLU(2.3) = max(0, 2.3) = 2.3", FS_SMALL, GREEN_ACCENT, "bold"), + (" → neuron aktywny! Przekazuje sygnał", FS_SMALL, GRAY5, "normal"), + ("", 0, "", ""), + ("Szybsza niż sigmoid/tanh", FS_SMALL, GRAY5, "normal"), + ("(brak exp() → szybkie obliczenia)", FS_SMALL, GRAY5, "normal"), + ] + for txt, size, color, weight in lines: + if txt == "": + y -= 0.2 + continue + ax.text(0.5, y, txt, fontsize=size, color=color, fontweight=weight, va="top") + y -= 0.5 + + _save_figure("q23_relu.png") + + +def generate_dot_product() -> None: + """Generate dot product.""" + _fig, axes = plt.subplots(1, 3, figsize=(11, 3.5)) + + # --- Panel 1: Concept --- + ax = axes[0] + ax.set_xlim(0, 10) + ax.set_ylim(0, 10) + ax.axis("off") + ax.set_title( + "Iloczyn skalarny\n(dot product)", fontsize=FS_TITLE, fontweight="bold" + ) + + y = 8.5 + lines = [ + ("Dwa wektory (listy liczb) → JEDNA liczba", FS, BLACK, "bold"), + ("", 0, "", ""), + ("a = [a₁, a₂, a₃] b = [b₁, b₂, b₃]", FS, ACCENT, "normal"), + ("", 0, "", ""), + ("a · b = a₁·b₁ + a₂·b₂ + a₃·b₃", FS, BLACK, "bold"), + ("", 0, "", ""), + ("Przykład:", FS, BLACK, "bold"), + ("a = [1, 3, -2] b = [4, -1, 5]", FS_SMALL, BLACK, "normal"), + ("a·b = 1·4 + 3·(-1) + (-2)·5", FS_SMALL, BLACK, "normal"), + (" = 4 + (-3) + (-10) = -9", FS_SMALL, RED_ACCENT, "bold"), + ("", 0, "", ""), + ( + 'Duży wynik → wektory „podobne" (w tym samym kierunku)', + FS_SMALL, + GREEN_ACCENT, + "normal", + ), + ('Mały/ujemny → wektory „różne"', FS_SMALL, RED_ACCENT, "normal"), + ] + for txt, size, color, weight in lines: + if txt == "": + y -= 0.25 + continue + ax.text(0.5, y, txt, fontsize=size, color=color, fontweight=weight, va="top") + y -= 0.55 + + # --- Panel 2: Convolution as dot product --- + ax = axes[1] + ax.set_xlim(-0.5, 5.5) + ax.set_ylim(-0.5, 5.5) + ax.set_aspect("equal") + ax.set_title( + "Konwolucja = iloczyn skalarny\nfiltra x fragment obrazu", + fontsize=FS_TITLE, + fontweight="bold", + ) + + # Filter 3x3 + filter_vals = [[-1, 0, 1], [-1, 0, 1], [-1, 0, 1]] + for i in range(3): + for j in range(3): + rect = patches.Rectangle( + (j - 0.4, 4 - i - 0.4), + 0.8, + 0.8, + facecolor=ACCENT_LIGHT, + edgecolor=BLACK, + linewidth=0.8, + ) + ax.add_patch(rect) + ax.text( + j, + 4 - i, + str(filter_vals[i][j]), + ha="center", + va="center", + fontsize=FS, + fontweight="bold", + ) + + ax.text(1, 1.5, "Filtr", ha="center", fontsize=FS, fontweight="bold", color=ACCENT) + + # Image patch + img_vals = [[50, 50, 200], [50, 50, 200], [50, 50, 200]] + for i in range(3): + for j in range(3): + rect = patches.Rectangle( + (j + 2.6, 4 - i - 0.4), + 0.8, + 0.8, + facecolor=GRAY2, + edgecolor=BLACK, + linewidth=0.8, + ) + ax.add_patch(rect) + ax.text( + j + 3, + 4 - i, + str(img_vals[i][j]), + ha="center", + va="center", + fontsize=FS, + fontweight="bold", + ) + + ax.text( + 4, + 1.5, + "Fragment\nobrazu", + ha="center", + fontsize=FS, + fontweight="bold", + color=GRAY5, + ) + + ax.text( + 2.5, + 0.5, + "(-1)·50 + 0·50 + 1·200 +\n" + "(-1)·50 + 0·50 + 1·200 +\n" + "(-1)·50 + 0·50 + 1·200\n= 450 (krawędź!)", + ha="center", + fontsize=FS_TINY, + fontweight="bold", + bbox={"boxstyle": "round", "facecolor": GRAY1, "edgecolor": GREEN_ACCENT}, + ) + + ax.axis("off") + + # --- Panel 3: Vector visualization --- + ax = axes[2] + # Draw two vectors + ax.quiver( + 0, + 0, + 3, + 4, + angles="xy", + scale_units="xy", + scale=1, + color=ACCENT, + width=0.025, + label="a = [3, 4]", + ) + ax.quiver( + 0, + 0, + 4, + 1, + angles="xy", + scale_units="xy", + scale=1, + color=RED_ACCENT, + width=0.025, + label="b = [4, 1]", + ) + + # Show angle + theta = np.linspace(np.arctan2(1, 4), np.arctan2(4, 3), 30) + r = 1.5 + ax.plot(r * np.cos(theta), r * np.sin(theta), color=GREEN_ACCENT, linewidth=1.5) + ax.text(1.8, 1.3, "θ", fontsize=FS, color=GREEN_ACCENT, fontweight="bold") + + ax.text(3.2, 4.2, "a", fontsize=FS, color=ACCENT, fontweight="bold") + ax.text(4.2, 1.2, "b", fontsize=FS, color=RED_ACCENT, fontweight="bold") + + ax.text( + 2.5, + -1.0, + "a · b = |a|·|b|·cos(θ)\n= 3·4 + 4·1 = 16", + ha="center", + fontsize=FS_SMALL, + fontweight="bold", + bbox={"boxstyle": "round", "facecolor": GRAY1, "edgecolor": GRAY3}, + ) + ax.text( + 2.5, + -2.0, + 'Mały kąt θ → duży dot product\n= wektory „zgadają się"', + ha="center", + fontsize=FS_TINY, + color=GRAY5, + ) + + ax.set_xlim(-0.5, 5.5) + ax.set_ylim(-2.5, 5.5) + ax.set_aspect("equal") + ax.grid(visible=True, alpha=0.2) + ax.legend(fontsize=FS_SMALL, loc="upper left") + ax.set_title("Geometrycznie: kąt", fontsize=FS_TITLE, fontweight="bold") + + _save_figure("q23_dot_product.png") diff --git a/python_pkg/praca_magisterska_video/generate_images/_q23_otsu_watershed.py b/python_pkg/praca_magisterska_video/generate_images/_q23_otsu_watershed.py new file mode 100644 index 0000000..ac44fd1 --- /dev/null +++ b/python_pkg/praca_magisterska_video/generate_images/_q23_otsu_watershed.py @@ -0,0 +1,408 @@ +"""Otsu thresholding and watershed diagram generators.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from _q23_common import ( + _RIDGE_X, + _VALLEY2_END, + ACCENT, + ACCENT_LIGHT, + BLACK, + FS, + FS_SMALL, + FS_TITLE, + GRAY1, + GRAY2, + GRAY3, + GRAY4, + GRAY5, + GREEN_ACCENT, + RED_ACCENT, + _render_text_lines, + _save_figure, + np, + plt, + rng, +) +from matplotlib.patches import FancyBboxPatch + +if TYPE_CHECKING: + from matplotlib.axes import Axes + + +def _draw_otsu_variance_panel(ax: Axes) -> None: + """Draw panel 2: within-class variance explanation.""" + ax.set_xlim(0, 10) + ax.set_ylim(0, 10) + ax.axis("off") + ax.set_title("Wariancja wewnątrzklasowa", fontsize=FS_TITLE, fontweight="bold") + + texts = [ + ( + "Wariancja = jak bardzo wartości\nróżnią się od średniej", + FS, + "black", + "normal", + ), + ("", 0, "black", "normal"), + ("Klasa 0 (piksele ≤ T):", FS, ACCENT, "bold"), + (" wartości: 30, 50, 45, 60, 55", FS_SMALL, "black", "normal"), + (" średnia μ₀ = 48", FS_SMALL, "black", "normal"), + (" σ₀² = ((30-48)²+(50-48)²+...)/5 = 108", FS_SMALL, "black", "normal"), + ("", 0, "black", "normal"), + ("Klasa 1 (piksele > T):", FS, RED_ACCENT, "bold"), + (" wartości: 180, 200, 190, 210, 195", FS_SMALL, "black", "normal"), + (" średnia μ₁ = 195", FS_SMALL, "black", "normal"), + (" σ₁² = ((180-195)²+...)/5 = 100", FS_SMALL, "black", "normal"), + ("", 0, "black", "normal"), + ("σ²_wewnątrz = w₀·σ₀² + w₁·σ₁²", FS, BLACK, "bold"), + ("= 0.6·108 + 0.4·100 = 104.8", FS_SMALL, "black", "normal"), + ("", 0, "black", "normal"), + ("Otsu próbuje KAŻDE T: 0,1,...,255", FS_SMALL, GREEN_ACCENT, "bold"), + ("Wybiera T dające MINIMUM σ²_wewnątrz", FS_SMALL, GREEN_ACCENT, "bold"), + ] + _render_text_lines( + ax, + texts, + x_pos=0.3, + start_y=9.2, + y_step=0.55, + y_empty_step=0.25, + ) + + +def generate_otsu_bimodal() -> None: + """Generate otsu bimodal.""" + _fig, axes = plt.subplots(1, 3, figsize=(11, 3.5)) + + # --- Panel 1: Bimodal histogram --- + ax = axes[0] + dark = rng.normal(60, 20, 3000).clip(0, 255) + bright = rng.normal(190, 25, 2000).clip(0, 255) + all_pixels = np.concatenate([dark, bright]) + + counts, _bins, _bars = ax.hist( + all_pixels, bins=64, color=GRAY3, edgecolor=GRAY5, linewidth=0.5 + ) + ax.axvline( + x=128, color=RED_ACCENT, linewidth=2, linestyle="--", label="Próg Otsu T=128" + ) + ax.fill_betweenx([0, max(counts) * 1.1], 0, 128, alpha=0.12, color=ACCENT) + ax.fill_betweenx([0, max(counts) * 1.1], 128, 255, alpha=0.12, color=RED_ACCENT) + ax.text( + 45, + max(counts) * 0.85, + "Klasa 0\n(tło)", + ha="center", + fontsize=FS, + fontweight="bold", + color=ACCENT, + ) + ax.text( + 195, + max(counts) * 0.85, + "Klasa 1\n(obiekt)", + ha="center", + fontsize=FS, + fontweight="bold", + color=RED_ACCENT, + ) + ax.annotate( + "Garb 1", + xy=(60, max(counts) * 0.6), + fontsize=FS_SMALL, + ha="center", + arrowprops={"arrowstyle": "->", "color": GRAY5}, + xytext=(30, max(counts) * 0.45), + ) + ax.annotate( + "Garb 2", + xy=(190, max(counts) * 0.5), + fontsize=FS_SMALL, + ha="center", + arrowprops={"arrowstyle": "->", "color": GRAY5}, + xytext=(220, max(counts) * 0.35), + ) + ax.set_xlabel("Jasność piksela (0-255)", fontsize=FS) + ax.set_ylabel("Liczba pikseli", fontsize=FS) + ax.set_title("Histogram bimodalny", fontsize=FS_TITLE, fontweight="bold") + ax.legend(fontsize=FS_SMALL, loc="upper right") + ax.set_xlim(0, 255) + + # --- Panel 2: Within-class variance explanation --- + _draw_otsu_variance_panel(axes[1]) + + # --- Panel 3: Jednorodność explanation --- + ax = axes[2] + ax.set_xlim(0, 10) + ax.set_ylim(0, 10) + ax.axis("off") + ax.set_title('"Jednorodne" = małe σ²', fontsize=FS_TITLE, fontweight="bold") + + # Draw two clusters + # Good separation + c0 = rng.normal(2, 0.4, 15) + c1 = rng.normal(7, 0.4, 15) + y_pos_0 = rng.uniform(6, 8, 15) + y_pos_1 = rng.uniform(6, 8, 15) + ax.scatter(c0, y_pos_0, c=ACCENT, s=30, zorder=5, label="Klasa 0") + ax.scatter(c1, y_pos_1, c=RED_ACCENT, s=30, zorder=5, label="Klasa 1") + ax.axvline(x=4.5, color=GREEN_ACCENT, linewidth=2, linestyle="--") + ax.text( + 4.5, + 8.8, + "T optymalny", + ha="center", + fontsize=FS_SMALL, + color=GREEN_ACCENT, + fontweight="bold", + ) + ax.text( + 2, 5.3, "σ₀² mała\n(skupione)", ha="center", fontsize=FS_SMALL, color=ACCENT + ) + ax.text( + 7, 5.3, "σ₁² mała\n(skupione)", ha="center", fontsize=FS_SMALL, color=RED_ACCENT + ) + ax.text( + 5, + 4, + "→ σ²_wewnątrz MINIMALNA\n→ klasy JEDNORODNE\n→ dobra segmentacja!", + ha="center", + fontsize=FS, + fontweight="bold", + color=GREEN_ACCENT, + ) + + # Bad separation + c0b = rng.normal(3.5, 1.5, 15) + c1b = rng.normal(6, 1.5, 15) + y_pos_0b = rng.uniform(1, 3, 15) + y_pos_1b = rng.uniform(1, 3, 15) + ax.scatter(c0b, y_pos_0b, c=ACCENT, s=30, marker="x", zorder=5) + ax.scatter(c1b, y_pos_1b, c=RED_ACCENT, s=30, marker="x", zorder=5) + ax.axvline(x=4.5, color=GRAY4, linewidth=1, linestyle=":", ymin=0, ymax=0.35) + ax.text( + 5, + 0.3, + "σ²_wewnątrz DUŻA → klasy mieszają się → zły próg", + ha="center", + fontsize=FS_SMALL, + color=GRAY5, + ) + + ax.legend(fontsize=FS_SMALL, loc="upper left") + + _save_figure("q23_otsu_bimodal.png") + + +def _draw_watershed_result_panel(ax: Axes) -> None: + """Draw panel 3: watershed result with over-segmentation problem.""" + ax.set_xlim(0, 10) + ax.set_ylim(0, 10) + ax.axis("off") + ax.set_title("Krok 3: wynik", fontsize=FS_TITLE, fontweight="bold") + + rect1 = FancyBboxPatch( + (0.5, 6), + 3.5, + 3.2, + boxstyle="round,pad=0.1", + facecolor=ACCENT_LIGHT, + edgecolor=BLACK, + linewidth=1, + ) + ax.add_patch(rect1) + ax.text(2.25, 8.8, "Ideał: 2 segmenty", fontsize=FS, ha="center", fontweight="bold") + ax.text(2.25, 7.5, "Segment A Segment B", fontsize=FS_SMALL, ha="center") + ax.text( + 2.25, + 6.7, + "(po marker-controlled)", + fontsize=FS_SMALL, + ha="center", + color=GREEN_ACCENT, + ) + + rect2 = FancyBboxPatch( + (5.5, 6), + 4, + 3.2, + boxstyle="round,pad=0.1", + facecolor="#FFCDD2", + edgecolor=BLACK, + linewidth=1, + ) + ax.add_patch(rect2) + ax.text( + 7.5, + 8.8, + "Problem: over-segmentation", + fontsize=FS, + ha="center", + fontweight="bold", + color=RED_ACCENT, + ) + ax.text( + 7.5, + 7.8, + "47 regionów zamiast 2!", + fontsize=FS_SMALL, + ha="center", + color=RED_ACCENT, + ) + ax.text(7.5, 7.1, "Każde mini-minimum", fontsize=FS_SMALL, ha="center") + ax.text(7.5, 6.5, '→ osobna „dolina"', fontsize=FS_SMALL, ha="center") + + # Apply marker-controlled solution + rect3 = FancyBboxPatch( + (1, 0.5), + 8, + 4.5, + boxstyle="round,pad=0.15", + facecolor=GRAY1, + edgecolor=GREEN_ACCENT, + linewidth=1.5, + ) + ax.add_patch(rect3) + ax.text( + 5, + 4.3, + "Rozwiązanie: Marker-controlled watershed", + fontsize=FS, + ha="center", + fontweight="bold", + color=GREEN_ACCENT, + ) + ax.text( + 5, + 3.4, + '1. Zaznacz ręcznie „seeds" (markery) w każdym obiekcie', + fontsize=FS_SMALL, + ha="center", + ) + ax.text( + 5, + 2.7, + "2. Zalewaj TYLKO od tych markerów (nie od wszystkich minimów)", + fontsize=FS_SMALL, + ha="center", + ) + ax.text( + 5, + 2.0, + "3. Eliminuje fałszywe doliny z szumu", + fontsize=FS_SMALL, + ha="center", + ) + ax.text( + 5, + 1.2, + "Wynik: tyle segmentów, ile podano markerów", + fontsize=FS_SMALL, + ha="center", + fontweight="bold", + ) + + +def generate_watershed() -> None: + """Generate watershed.""" + _fig, axes = plt.subplots(1, 3, figsize=(11, 3.8)) + + # --- Panel 1: Image as topographic surface --- + ax = axes[0] + x = np.linspace(0, 10, 200) + # Create a surface with two valleys and a ridge + surface = ( + 3 * np.exp(-((x - 3) ** 2) / 1.5) + + 4 * np.exp(-((x - 7) ** 2) / 1.2) + + 0.5 * np.sin(x * 2) + + 1 + ) + # Invert: valleys at objects (dark), peaks at boundaries (bright) + surface_inv = 6 - surface + 1 + + ax.fill_between(x, 0, surface_inv, color=GRAY2, alpha=0.7) + ax.plot(x, surface_inv, color=BLACK, linewidth=1.5) + + # Mark valleys + ax.annotate( + "Dolina 1\n(obiekt A)", + xy=(3, surface_inv[60]), + fontsize=FS_SMALL, + ha="center", + va="bottom", + arrowprops={"arrowstyle": "->", "color": ACCENT}, + xytext=(1.5, 5.5), + ) + ax.annotate( + "Dolina 2\n(obiekt B)", + xy=(7, surface_inv[140]), + fontsize=FS_SMALL, + ha="center", + va="bottom", + arrowprops={"arrowstyle": "->", "color": RED_ACCENT}, + xytext=(8.5, 5.5), + ) + # Mark ridge + ax.annotate( + "Grań\n(granica)", + xy=(5, surface_inv[100]), + fontsize=FS_SMALL, + ha="center", + va="bottom", + arrowprops={"arrowstyle": "->", "color": GREEN_ACCENT}, + xytext=(5, 6.5), + ) + + ax.set_xlabel("Pozycja piksela", fontsize=FS) + ax.set_ylabel("Jasność (= wysokość)", fontsize=FS) + ax.set_title("Krok 1: obraz → teren", fontsize=FS_TITLE, fontweight="bold") + ax.set_ylim(0, 7) + + # --- Panel 2: Flooding --- + ax = axes[1] + ax.fill_between(x, 0, surface_inv, color=GRAY2, alpha=0.7) + ax.plot(x, surface_inv, color=BLACK, linewidth=1.5) + + # Water level + water_level = 3.2 + + # Fill water in valley 1 + x_v1 = x[(x > 1) & (x < _RIDGE_X)] + s_v1 = surface_inv[(x > 1) & (x < _RIDGE_X)] + ax.fill_between( + x_v1, s_v1, water_level, where=s_v1 < water_level, color=ACCENT_LIGHT, alpha=0.6 + ) + # Fill water in valley 2 + x_v2 = x[(x > _RIDGE_X) & (x < _VALLEY2_END)] + s_v2 = surface_inv[(x > _RIDGE_X) & (x < _VALLEY2_END)] + ax.fill_between( + x_v2, s_v2, water_level, where=s_v2 < water_level, color="#FFCDD2", alpha=0.6 + ) + + ax.axhline(y=water_level, color=ACCENT, linewidth=1, linestyle="--", alpha=0.5) + ax.text(3, 2.5, "Woda A", fontsize=FS, ha="center", color=ACCENT, fontweight="bold") + ax.text( + 7, 2.2, "Woda B", fontsize=FS, ha="center", color=RED_ACCENT, fontweight="bold" + ) + ax.annotate( + "Tu się spotkają!\n→ GRANICA", + xy=(5, surface_inv[100]), + fontsize=FS_SMALL, + ha="center", + color=GREEN_ACCENT, + fontweight="bold", + arrowprops={"arrowstyle": "->", "color": GREEN_ACCENT}, + xytext=(5, 6.2), + ) + + ax.set_xlabel("Pozycja piksela", fontsize=FS) + ax.set_title("Krok 2: zalewanie", fontsize=FS_TITLE, fontweight="bold") + ax.set_ylim(0, 7) + + # --- Panel 3: Result with problem --- + _draw_watershed_result_panel(axes[2]) + + _save_figure("q23_watershed.png") diff --git a/python_pkg/praca_magisterska_video/generate_images/_q23_receptive_transformer.py b/python_pkg/praca_magisterska_video/generate_images/_q23_receptive_transformer.py new file mode 100644 index 0000000..28491a3 --- /dev/null +++ b/python_pkg/praca_magisterska_video/generate_images/_q23_receptive_transformer.py @@ -0,0 +1,286 @@ +"""Receptive field and transformer diagram generators.""" + +from __future__ import annotations + +from _q23_common import ( + _HIGHLIGHT_END, + _HIGHLIGHT_START, + ACCENT, + ACCENT_LIGHT, + BLACK, + FS, + FS_SMALL, + FS_TITLE, + GRAY3, + GRAY5, + GREEN_ACCENT, + RED_ACCENT, + WHITE, + _save_figure, + plt, +) +from matplotlib import patches + + +def generate_receptive_field() -> None: + """Generate receptive field.""" + _fig, axes = plt.subplots(1, 3, figsize=(11, 4)) + + def draw_grid( + ax: patches.Axes, + size: int, + highlight_cells: list[tuple[int, int]], + highlight_color: str, + title: str, + grid_offset: tuple[int, int] = (0, 0), + ) -> None: + """Draw grid.""" + ox, oy = grid_offset + for i in range(size): + for j in range(size): + color = WHITE + if (i, j) in highlight_cells: + color = highlight_color + rect = patches.Rectangle( + (ox + j, oy + size - 1 - i), + 1, + 1, + facecolor=color, + edgecolor=GRAY3, # Use GRAY3 instead of GRAY4 since unused + linewidth=0.5, + ) + ax.add_patch(rect) + ax.set_title(title, fontsize=FS_TITLE, fontweight="bold") + + # --- Panel 1: Standard 3x3 conv receptive field --- + ax = axes[0] + ax.set_xlim(-0.5, 7.5) + ax.set_ylim(-1, 8) + ax.set_aspect("equal") + ax.axis("off") + + # 7x7 input grid + highlight_3x3 = [ + (2, 2), + (2, 3), + (2, 4), + (3, 2), + (3, 3), + (3, 4), + (4, 2), + (4, 3), + (4, 4), + ] + draw_grid(ax, 7, highlight_3x3, ACCENT_LIGHT, "Zwykła conv 3x3") + ax.text( + 3.5, + -0.5, + "RF = 3x3 pikseli", + fontsize=FS, + ha="center", + fontweight="bold", + color=ACCENT, + ) + + # --- Panel 2: Dilated conv (rate=2) --- + ax = axes[1] + ax.set_xlim(-0.5, 7.5) + ax.set_ylim(-1, 8) + ax.set_aspect("equal") + ax.axis("off") + + # 7x7 input grid with dilated highlights + highlight_dilated = [ + (1, 1), + (1, 3), + (1, 5), + (3, 1), + (3, 3), + (3, 5), + (5, 1), + (5, 3), + (5, 5), + ] + draw_grid(ax, 7, highlight_dilated, "#FFCDD2", "Dilated conv 3x3\n(rate=2)") + ax.text( + 3.5, + -0.5, + "RF = 5x5, ale 9 parametrów!", + fontsize=FS, + ha="center", + fontweight="bold", + color=RED_ACCENT, + ) + + # Connect dots to show pattern + dots_x = [1.5, 3.5, 5.5, 1.5, 3.5, 5.5, 1.5, 3.5, 5.5] + dots_y = [5.5, 5.5, 5.5, 3.5, 3.5, 3.5, 1.5, 1.5, 1.5] + ax.scatter(dots_x, dots_y, c=RED_ACCENT, s=30, zorder=5) + + # --- Panel 3: Comparison --- + ax = axes[2] + ax.set_xlim(0, 10) + ax.set_ylim(0, 10) + ax.axis("off") + ax.set_title( + "Receptive Field\n(pole widzenia neuronu)", fontsize=FS_TITLE, fontweight="bold" + ) + + y = 8.5 + lines = [ + ("RF = ile pikseli WEJŚCIOWYCH", FS, BLACK, "bold"), + ("wpływa na JEDEN piksel wyjścia", FS, BLACK, "bold"), + ("", 0, "", ""), + ("Rate (współczynnik dylatacji):", FS, BLACK, "bold"), + (' rate=1: filtr „dotyka" sąsiadów', FS_SMALL, BLACK, "normal"), + (" rate=2: co drugi piksel → RF = 5x5", FS_SMALL, BLACK, "normal"), + (" rate=3: co trzeci → RF = 7x7", FS_SMALL, BLACK, "normal"), + (" WIĘCEJ kontekstu, TE SAME wagi!", FS_SMALL, GREEN_ACCENT, "bold"), + ("", 0, "", ""), + ("Dlaczego ważne w segmentacji?", FS, BLACK, "bold"), + (" Piksel sam nie wie czym jest.", FS_SMALL, BLACK, "normal"), + (" Potrzebuje KONTEKSTU (otoczenia).", FS_SMALL, BLACK, "normal"), + (" Większe RF → widzi obok budynki", FS_SMALL, BLACK, "normal"), + (' → wie, że TEN piksel to „droga"', FS_SMALL, GREEN_ACCENT, "bold"), + ("", 0, "", ""), + ("Global Average Pooling:", FS, BLACK, "bold"), + (" Mapa HxWxC → 1x1xC", FS_SMALL, BLACK, "normal"), + (" Średnia z CAŁEGO feature map", FS_SMALL, BLACK, "normal"), + (" RF = nieskończone (cały obraz)", FS_SMALL, GREEN_ACCENT, "bold"), + ] + for txt, size, color, weight in lines: + if txt == "": + y -= 0.2 + continue + ax.text(0.5, y, txt, fontsize=size, color=color, fontweight=weight, va="top") + y -= 0.45 + + _save_figure("q23_receptive_field.png") + + +def generate_transformer() -> None: + """Generate transformer.""" + _fig, axes = plt.subplots(1, 3, figsize=(11, 4)) + + # --- Panel 1: CNN local vs Transformer global --- + ax = axes[0] + ax.set_xlim(-0.5, 8.5) + ax.set_ylim(-1.5, 8.5) + ax.set_aspect("equal") + ax.axis("off") + ax.set_title("CNN: widzi LOKALNIE", fontsize=FS_TITLE, fontweight="bold") + + # Draw 8x8 grid + for i in range(8): + for j in range(8): + color = WHITE + if ( + _HIGHLIGHT_START <= i <= _HIGHLIGHT_END + and _HIGHLIGHT_START <= j <= _HIGHLIGHT_END + ): + color = ACCENT_LIGHT + rect = patches.Rectangle( + (j, 7 - i), 1, 1, facecolor=color, edgecolor=GRAY3, linewidth=0.3 + ) + ax.add_patch(rect) + + # Highlight center + rect = patches.Rectangle( + (4, 4), 1, 1, facecolor=RED_ACCENT, edgecolor=BLACK, linewidth=1.5, alpha=0.7 + ) + ax.add_patch(rect) + ax.text( + 4.5, + 4.5, + "?", + ha="center", + va="center", + fontsize=FS, + fontweight="bold", + color=WHITE, + ) + ax.text( + 4.5, + -0.8, + "Filtr 3x3 widzi tylko\n9 sąsiednich pikseli", + fontsize=FS_SMALL, + ha="center", + color=ACCENT, + ) + + # --- Panel 2: Transformer global --- + ax = axes[1] + ax.set_xlim(-0.5, 8.5) + ax.set_ylim(-1.5, 8.5) + ax.set_aspect("equal") + ax.axis("off") + ax.set_title("Transformer: widzi GLOBALNIE", fontsize=FS_TITLE, fontweight="bold") + + # Draw 8x8 grid all highlighted + for i in range(8): + for j in range(8): + color = "#FFCDD2" + rect = patches.Rectangle( + (j, 7 - i), 1, 1, facecolor=color, edgecolor=GRAY3, linewidth=0.3 + ) + ax.add_patch(rect) + + rect = patches.Rectangle( + (4, 4), 1, 1, facecolor=RED_ACCENT, edgecolor=BLACK, linewidth=1.5, alpha=0.9 + ) + ax.add_patch(rect) + ax.text( + 4.5, + 4.5, + "?", + ha="center", + va="center", + fontsize=FS, + fontweight="bold", + color=WHITE, + ) + ax.text( + 4.5, + -0.8, + 'Self-attention „pyta"\nALL 64 piksele naraz', + fontsize=FS_SMALL, + ha="center", + color=RED_ACCENT, + ) + + # --- Panel 3: SOTA + Transformer explanation --- + ax = axes[2] + ax.set_xlim(0, 10) + ax.set_ylim(0, 10) + ax.axis("off") + ax.set_title("Transformer & SOTA", fontsize=FS_TITLE, fontweight="bold") + + y = 9.2 + lines = [ + ("Transformer:", FS, BLACK, "bold"), + (" Architektura z 2017 (Vaswani et al.)", FS_SMALL, BLACK, "normal"), + (" Oryginalnie do NLP (tłumaczenie)", FS_SMALL, BLACK, "normal"), + (" Kluczowy mechanizm: SELF-ATTENTION", FS_SMALL, ACCENT, "bold"), + ("", 0, "", ""), + ("Self-attention w skrócie:", FS, BLACK, "bold"), + (" Każdy piksel tworzy trzy wektory:", FS_SMALL, BLACK, "normal"), + (' Q (Query — „czego szukam?")', FS_SMALL, ACCENT, "normal"), + (' K (Key — „co oferuję innych")', FS_SMALL, RED_ACCENT, "normal"), + (' V (Value — „moja wartość")', FS_SMALL, GREEN_ACCENT, "normal"), + (" Attention = softmax(Q·Kᵀ/√d)·V", FS_SMALL, BLACK, "bold"), + (" Koszt: O(n²) — n=liczba pikseli", FS_SMALL, RED_ACCENT, "normal"), + ("", 0, "", ""), + ("SOTA = State Of The Art:", FS, BLACK, "bold"), + (" Najlepszy znany wynik na benchmarku", FS_SMALL, BLACK, "normal"), + (' Np. „mIoU 85.1% na ADE20K = SOTA"', FS_SMALL, BLACK, "normal"), + (" Ciągle się zmienia (nowy paper", FS_SMALL, GRAY5, "normal"), + (" → nowy SOTA)", FS_SMALL, GRAY5, "normal"), + ] + for txt, size, color, weight in lines: + if txt == "": + y -= 0.15 + continue + ax.text(0.3, y, txt, fontsize=size, color=color, fontweight=weight, va="top") + y -= 0.45 + + _save_figure("q23_transformer_attention.png") diff --git a/python_pkg/praca_magisterska_video/generate_images/_q23_region_diy.py b/python_pkg/praca_magisterska_video/generate_images/_q23_region_diy.py new file mode 100644 index 0000000..fd917e9 --- /dev/null +++ b/python_pkg/praca_magisterska_video/generate_images/_q23_region_diy.py @@ -0,0 +1,408 @@ +"""Region growing and DIY thresholding diagram generators.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from _q23_common import ( + _BRIGHT_THRESHOLD, + _OTSU_THRESHOLD, + ACCENT, + ACCENT_LIGHT, + BLACK, + FS, + FS_SMALL, + FS_TINY, + FS_TITLE, + GRAY3, + GRAY4, + GRAY5, + GREEN_ACCENT, + RED_ACCENT, + WHITE, + _save_figure, + np, + plt, + rng, +) +from matplotlib import patches + +if TYPE_CHECKING: + from matplotlib.axes import Axes + + +def _draw_region_growing_grid(ax: Axes) -> None: + """Draw panel 2: region growing step-by-step grid.""" + ax.set_xlim(-0.5, 6.5) + ax.set_ylim(-1.5, 7.5) + ax.set_aspect("equal") + ax.axis("off") + ax.set_title( + "Region Growing: krok po kroku", + fontsize=FS_TITLE, + fontweight="bold", + ) + + pixel_grid = np.array( + [ + [150, 153, 148, 200, 210, 205], + [147, 155, 152, 195, 208, 200], + [145, 148, 160, 190, 195, 210], + [200, 195, 190, 155, 148, 150], + [210, 205, 200, 150, 152, 145], + [215, 208, 195, 148, 147, 155], + ] + ) + region_mask = np.array( + [ + [1, 1, 1, 0, 0, 0], + [1, 1, 1, 0, 0, 0], + [1, 1, 1, 0, 0, 0], + [0, 0, 0, 1, 1, 1], + [0, 0, 0, 1, 1, 1], + [0, 0, 0, 1, 1, 1], + ] + ) + + for i in range(6): + for j in range(6): + v = pixel_grid[i, j] + if region_mask[i, j] == 1 and v < _BRIGHT_THRESHOLD: + cell_color = ACCENT_LIGHT + elif region_mask[i, j] == 1: + cell_color = "#E0E0E0" + else: + cell_color = WHITE + if i == 1 and j == 1: + cell_color = "#FFD54F" + rect = patches.Rectangle( + (j, 5 - i), + 1, + 1, + facecolor=cell_color, + edgecolor=GRAY4, + linewidth=0.5, + ) + ax.add_patch(rect) + ax.text( + j + 0.5, + 5 - i + 0.5, + str(v), + ha="center", + va="center", + fontsize=FS_TINY, + fontweight="bold", + ) + + ax.annotate( + "SEED\n(155)", + xy=(1.5, 4.5), + fontsize=FS_SMALL, + ha="center", + color=RED_ACCENT, + fontweight="bold", + arrowprops={"arrowstyle": "->", "color": RED_ACCENT}, + xytext=(-0.5, 7), + ) + ax.text( + 3, + -0.8, + "Próg = 20\nNiebieski = region (|val - seed| < 20)", + fontsize=FS_TINY, + ha="center", + color=ACCENT, + ) + + +def _draw_bfs_expansion(ax: Axes) -> None: + """Draw panel 3: BFS expansion visualization.""" + ax.set_xlim(-0.5, 6.5) + ax.set_ylim(-1.5, 7.5) + ax.set_aspect("equal") + ax.axis("off") + ax.set_title( + "Rosnący region (BFS)", + fontsize=FS_TITLE, + fontweight="bold", + ) + + wave_colors = ["#FFD54F", "#FFF176", "#FFF9C4", ACCENT_LIGHT, "#B3D4FC"] + wave_labels = ["Seed", "Fala 1", "Fala 2", "Fala 3", "Fala 4"] + waves = [ + [(1, 1)], + [(0, 1), (1, 0), (1, 2), (2, 1)], + [(0, 0), (0, 2), (2, 0), (2, 2)], + ] + + for i in range(6): + for j in range(6): + cell_color = WHITE + for w_idx, wave in enumerate(waves): + if (i, j) in wave: + cell_color = wave_colors[w_idx] + rect = patches.Rectangle( + (j, 5 - i), + 1, + 1, + facecolor=cell_color, + edgecolor=GRAY4, + linewidth=0.5, + ) + ax.add_patch(rect) + + seed_x, seed_y = 1.5, 4.5 + for dx, dy, _label in [ + (0, 1, ""), + (0, -1, ""), + (1, 0, ""), + (-1, 0, ""), + ]: + ax.annotate( + "", + xy=(seed_x + dx * 0.7, seed_y + dy * 0.7), + xytext=(seed_x, seed_y), + arrowprops={ + "arrowstyle": "->", + "color": RED_ACCENT, + "lw": 1.2, + }, + ) + + ax.text( + 3, + -0.5, + "BFS: sprawdzaj sąsiadów,\ndodawaj podobne do kolejki", + fontsize=FS_TINY, + ha="center", + color=GRAY5, + ) + + for w_idx, (wave_color, label) in enumerate( + zip(wave_colors[:3], wave_labels[:3], strict=False) + ): + rect = patches.Rectangle( + (4, 6.5 - w_idx * 0.7), + 0.5, + 0.5, + facecolor=wave_color, + edgecolor=GRAY4, + linewidth=0.5, + ) + ax.add_patch(rect) + ax.text( + 4.8, + 6.75 - w_idx * 0.7, + label, + fontsize=FS_TINY, + va="center", + ) + + +def generate_region_growing() -> None: + """Generate region growing.""" + _fig, axes = plt.subplots(1, 3, figsize=(11, 4.2)) + + # --- Panel 1: Manual vs automatic seed --- + ax = axes[0] + ax.set_xlim(0, 10) + ax.set_ylim(0, 10) + ax.axis("off") + ax.set_title("Seed: ręcznie vs automatycznie", fontsize=FS_TITLE, fontweight="bold") + + y = 9.2 + lines = [ + ("Ręczny seed:", FS, ACCENT, "bold"), + (" Użytkownik klika na obraz", FS_SMALL, BLACK, "normal"), + (' → „tu jest obiekt, od tego zacznij"', FS_SMALL, BLACK, "normal"), + (" Użycie: segmentacja interaktywna", FS_SMALL, GRAY5, "normal"), + (" (np. Photoshop — magic wand tool)", FS_SMALL, GRAY5, "normal"), + ("", 0, "", ""), + ("Automatyczny seed:", FS, RED_ACCENT, "bold"), + (" 1. Histogram → lokalne maxima", FS_SMALL, BLACK, "normal"), + (" (najczęstsza jasność → seed)", FS_SMALL, GRAY5, "normal"), + (" 2. Grid: siatka co N pikseli", FS_SMALL, BLACK, "normal"), + (" (np. seed co 50 px → 100 seedów)", FS_SMALL, GRAY5, "normal"), + (" 3. Losowe próbkowanie", FS_SMALL, BLACK, "normal"), + (" 4. Ekstrema lokalne gradientu", FS_SMALL, BLACK, "normal"), + ("", 0, "", ""), + ("Dlaczego OR?", FS, GREEN_ACCENT, "bold"), + (" Ręczny → precyzyjny, ale wolny", FS_SMALL, BLACK, "normal"), + (" Auto → szybki, ale over-segmentation", FS_SMALL, BLACK, "normal"), + ] + for txt, size, color, weight in lines: + if txt == "": + y -= 0.15 + continue + ax.text(0.3, y, txt, fontsize=size, color=color, fontweight=weight, va="top") + y -= 0.45 + + # --- Panel 2: Region growing step by step --- + _draw_region_growing_grid(axes[1]) + + # --- Panel 3: BFS expansion --- + _draw_bfs_expansion(axes[2]) + + _save_figure("q23_region_growing.png") + + +def _draw_otsu_variance_and_pseudocode( + ax_var: Axes, + ax_code: Axes, + img: np.ndarray, +) -> int: + """Draw panels 4 and 5: Otsu variance plot and pseudocode.""" + thresholds = range(10, 245) + variances = [] + for t in thresholds: + c0 = img[img <= t].ravel() + c1 = img[img > t].ravel() + if len(c0) == 0 or len(c1) == 0: + variances.append(np.nan) + continue + w0 = len(c0) / len(img.ravel()) + w1 = len(c1) / len(img.ravel()) + var = w0 * np.var(c0) + w1 * np.var(c1) + variances.append(var) + + ax_var.plot(list(thresholds), variances, color=ACCENT, linewidth=1.5) + best_t = list(thresholds)[np.nanargmin(variances)] + ax_var.axvline( + x=best_t, + color=RED_ACCENT, + linewidth=1.5, + linestyle="--", + label=f"Otsu T={best_t}", + ) + ax_var.scatter( + [best_t], + [np.nanmin(variances)], + c=RED_ACCENT, + s=60, + zorder=5, + ) + ax_var.set_xlabel("Próg T", fontsize=FS_SMALL) + ax_var.set_ylabel("σ² wewnątrzklasowa", fontsize=FS_SMALL) + ax_var.set_title( + "Krok 4: Otsu szuka min σ²", + fontsize=FS, + fontweight="bold", + ) + ax_var.legend(fontsize=FS_TINY) + + ax_code.set_xlim(0, 10) + ax_code.set_ylim(0, 10) + ax_code.axis("off") + ax_code.set_title("Pseudokod Otsu", fontsize=FS, fontweight="bold") + + code_lines = [ + "best_T = 0", + "min_var = ∞", + "", + "for T in 0..255:", + " c0 = piksele z jasność ≤ T", + " c1 = piksele z jasność > T", + " w0 = len(c0) / len(all)", + " w1 = len(c1) / len(all)", + " var = w0·var(c0) + w1·var(c1)", + " if var < min_var:", + " min_var = var", + " best_T = T", + "", + "return best_T # optymalny próg", + ] + for i, line in enumerate(code_lines): + txt_color = ACCENT if "best_T = T" in line or "return" in line else BLACK + ax_code.text( + 0.5, + 9.5 - i * 0.65, + line, + fontsize=FS_TINY, + fontfamily="monospace", + color=txt_color, + fontweight="bold" if txt_color == ACCENT else "normal", + ) + return int(best_t) + + +def generate_diy_thresholding() -> None: + """Generate diy thresholding.""" + _fig, axes = plt.subplots(2, 3, figsize=(11, 7)) + + # Create a simple synthetic image: dark circle on bright background + size = 64 + img = np.ones((size, size)) * 200 # bright background + yy, xx = np.mgrid[:size, :size] + mask = ((xx - 32) ** 2 + (yy - 32) ** 2) < 15**2 + img[mask] = 60 # dark circle + # Add some noise + img += rng.normal(0, 10, img.shape) + img = np.clip(img, 0, 255) + + # --- Panel 1: Original image --- + ax = axes[0, 0] + ax.imshow(img, cmap="gray", vmin=0, vmax=255) + ax.set_title("Krok 1: obraz wejściowy", fontsize=FS, fontweight="bold") + ax.axis("off") + ax.text(32, -3, "64x64 pikseli, szare", fontsize=FS_TINY, ha="center") + + # --- Panel 2: Histogram --- + ax = axes[0, 1] + counts, _bins, _ = ax.hist( + img.ravel(), bins=50, color=GRAY3, edgecolor=GRAY5, linewidth=0.5 + ) + ax.axvline( + x=128, color=RED_ACCENT, linewidth=2, linestyle="--", label="T=128 (Otsu)" + ) + ax.set_xlabel("Jasność", fontsize=FS_SMALL) + ax.set_ylabel("Piksele", fontsize=FS_SMALL) + ax.set_title("Krok 2: histogram\n(bimodalny!)", fontsize=FS, fontweight="bold") + ax.legend(fontsize=FS_TINY) + ax.annotate( + "Garb 1\n(obiekt)", + xy=(60, max(counts) * 0.5), + fontsize=FS_TINY, + ha="center", + color=ACCENT, + fontweight="bold", + ) + ax.annotate( + "Garb 2\n(tło)", + xy=(200, max(counts) * 0.5), + fontsize=FS_TINY, + ha="center", + color=RED_ACCENT, + fontweight="bold", + ) + + # --- Panel 3: Thresholding result --- + ax = axes[0, 2] + binary = (img > _OTSU_THRESHOLD).astype(float) + ax.imshow(binary, cmap="gray", vmin=0, vmax=1) + ax.set_title("Krok 3: progowanie T=128", fontsize=FS, fontweight="bold") + ax.axis("off") + ax.text(32, -3, "Biały = tło, Czarny = obiekt", fontsize=FS_TINY, ha="center") + + # --- Panels 4+5: Otsu variance plot + pseudocode --- + best_t = _draw_otsu_variance_and_pseudocode( + axes[1, 0], + axes[1, 1], + img, + ) + + # --- Panel 6: Final result with Otsu --- + ax = axes[1, 2] + binary_otsu = (img > best_t).astype(float) + ax.imshow(binary_otsu, cmap="gray", vmin=0, vmax=1) + ax.set_title(f"Krok 5: wynik Otsu (T={best_t})", fontsize=FS, fontweight="bold") + ax.axis("off") + ax.text( + 32, + -3, + "Automatyczny próg!", + fontsize=FS_TINY, + ha="center", + color=GREEN_ACCENT, + fontweight="bold", + ) + + _save_figure("q23_diy_thresholding.png") diff --git a/python_pkg/praca_magisterska_video/generate_images/_q24_common.py b/python_pkg/praca_magisterska_video/generate_images/_q24_common.py new file mode 100644 index 0000000..bf30e5c --- /dev/null +++ b/python_pkg/praca_magisterska_video/generate_images/_q24_common.py @@ -0,0 +1,186 @@ +"""Common utilities and constants for Q24 diagram generation. + +Monochrome, A4-printable PNGs (300 DPI). +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import TYPE_CHECKING + +import matplotlib as mpl + +mpl.use("Agg") + +import matplotlib.patches as mpatches +from matplotlib.patches import FancyBboxPatch +import matplotlib.pyplot as plt +import numpy as np + +if TYPE_CHECKING: + from matplotlib.axes import Axes + from matplotlib.figure import Figure + +_logger = logging.getLogger(__name__) + +rng = np.random.default_rng(42) + +DPI = 300 +BG = "white" +LN = "black" +FS = 8 +FS_TITLE = 11 +FS_SMALL = 6.5 +FS_LABEL = 9 +OUTPUT_DIR = str(Path(__file__).resolve().parent / "img") +Path(OUTPUT_DIR).mkdir(parents=True, exist_ok=True) + +GRAY1 = "#E8E8E8" +GRAY2 = "#D0D0D0" +GRAY3 = "#B8B8B8" +GRAY4 = "#F5F5F5" +GRAY5 = "#C0C0C0" + +_PIXEL_BRIGHT_THRESH = 127 +_GRADIENT_BRIGHT_THRESH = 100 +_DATA_BRIGHT_THRESH = 5 +_II_BRIGHT_THRESH = 25 +_DOTS_STAGE_IDX = 2 + + +def draw_box( + ax: Axes, + x: float, + y: float, + w: float, + h: float, + text: str, + *, + fill: str = "white", + lw: float = 1.2, + fontsize: float = FS, + fontweight: str = "normal", + ha: str = "center", + va: str = "center", + rounded: bool = True, + edgecolor: str = LN, + linestyle: str = "-", +) -> None: + """Draw box.""" + if rounded: + rect = FancyBboxPatch( + (x, y), + w, + h, + boxstyle="round,pad=0.05", + lw=lw, + edgecolor=edgecolor, + facecolor=fill, + linestyle=linestyle, + ) + else: + rect = mpatches.Rectangle( + (x, y), + w, + h, + lw=lw, + edgecolor=edgecolor, + facecolor=fill, + linestyle=linestyle, + ) + ax.add_patch(rect) + ax.text( + x + w / 2, + y + h / 2, + text, + ha=ha, + va=va, + fontsize=fontsize, + fontweight=fontweight, + wrap=True, + ) + + +def draw_arrow( + ax: Axes, + x1: float, + y1: float, + x2: float, + y2: float, + *, + lw: float = 1.2, + style: str = "->", + color: str = LN, +) -> None: + """Draw arrow.""" + ax.annotate( + "", + xy=(x2, y2), + xytext=(x1, y1), + arrowprops={"arrowstyle": style, "color": color, "lw": lw}, + ) + + +def save_fig(fig: Figure, name: str) -> None: + """Save fig.""" + path = str(Path(OUTPUT_DIR) / name) + fig.savefig(path, dpi=DPI, bbox_inches="tight", facecolor=BG, pad_inches=0.15) + plt.close(fig) + _logger.info(" Saved: %s", path) + + +def draw_table( + ax: Axes, + headers: list[str], + rows: list[list[str]], + x0: float, + y0: float, + col_widths: list[float], + *, + row_h: float = 0.4, + header_fill: str = GRAY2, + row_fills: list[str] | None = None, + fontsize: float = FS, + header_fontsize: float | None = None, +) -> None: + """Draw table.""" + if header_fontsize is None: + header_fontsize = fontsize + len(headers) + cx = x0 + for j, hdr in enumerate(headers): + draw_box( + ax, + cx, + y0, + col_widths[j], + row_h, + hdr, + fill=header_fill, + fontsize=header_fontsize, + fontweight="bold", + rounded=False, + ) + cx += col_widths[j] + for i, row in enumerate(rows): + cy = y0 - (i + 1) * row_h + cx = x0 + fill = GRAY4 if (i % 2 == 0) else "white" + if row_fills and i < len(row_fills): + fill = row_fills[i] + for j, cell in enumerate(row): + fw = "bold" if j == 0 else "normal" + draw_box( + ax, + cx, + cy, + col_widths[j], + row_h, + cell, + fill=fill, + fontsize=fontsize, + fontweight=fw, + rounded=False, + ) + cx += col_widths[j] diff --git a/python_pkg/praca_magisterska_video/generate_images/_q24_fpn_tasks_cnn.py b/python_pkg/praca_magisterska_video/generate_images/_q24_fpn_tasks_cnn.py new file mode 100644 index 0000000..563e5b9 --- /dev/null +++ b/python_pkg/praca_magisterska_video/generate_images/_q24_fpn_tasks_cnn.py @@ -0,0 +1,412 @@ +"""FPN, anchor boxes, detection tasks, and CNN architecture diagrams.""" + +from __future__ import annotations + +from _q24_common import ( + FS, + FS_SMALL, + FS_TITLE, + GRAY1, + GRAY2, + GRAY3, + GRAY4, + LN, + draw_arrow, + draw_box, + np, + plt, + save_fig, +) +import matplotlib.patches as mpatches + + +# ============================================================ +# 16. FPN (Feature Pyramid Network) +# ============================================================ +def draw_fpn() -> None: + """Draw fpn.""" + fig, ax = plt.subplots(figsize=(9, 5)) + ax.set_xlim(-0.5, 9.5) + ax.set_ylim(-0.5, 5.5) + ax.set_aspect("equal") + ax.axis("off") + ax.set_title( + "FPN (Feature Pyramid Network) — detekcja obiektów wszystkich rozmiarów", + fontsize=FS_TITLE, + fontweight="bold", + pad=12, + ) + + levels = [ + (0, 0, 2.0, 2.0, "C2\n56x56", "duże\ndetale"), + (0, 2.2, 1.5, 1.5, "C3\n28x28", ""), + (0, 3.9, 1.0, 1.0, "C4\n14x14", ""), + (0, 5.1, 0.6, 0.6, "C5\n7x7", "kontekst"), + ] + + for x, y, w, h, label, note in levels: + ax.add_patch( + mpatches.Rectangle((x, y - h), w, h, facecolor=GRAY4, edgecolor=LN, lw=1.5) + ) + ax.text( + x + w / 2, + y - h / 2, + label, + ha="center", + va="center", + fontsize=FS_SMALL, + fontweight="bold", + ) + if note: + ax.text( + x + w + 0.15, + y - h / 2, + note, + ha="left", + va="center", + fontsize=5, + style="italic", + ) + + ax.text( + 1.0, -0.3, "Bottom-up\n(backbone)", ha="center", fontsize=FS, fontweight="bold" + ) + + # Top-down + lateral + td_levels = [ + (4.5, 5.1, 0.6, 0.6, "P5"), + (4.5, 3.9, 1.0, 1.0, "P4"), + (4.5, 2.2, 1.5, 1.5, "P3"), + (4.5, 0, 2.0, 2.0, "P2"), + ] + + for x, y, w, h, label in td_levels: + ax.add_patch( + mpatches.Rectangle( + (x, y - h + h), w, h, facecolor=GRAY2, edgecolor=LN, lw=1.5 + ) + ) + ax.text( + x + w / 2, + y - h / 2 + h, + label, + ha="center", + va="center", + fontsize=FS_SMALL, + fontweight="bold", + ) + + # Lateral connections + for (_, y1, w1, h1, _, _), (x2, y2, _w2, h2, _) in zip( + levels, td_levels, strict=False + ): + draw_arrow(ax, w1 + 0.2, y1 - h1 / 2, x2 - 0.1, y2 + h2 / 2, lw=1, style="->") + + # Top-down arrows + for i in range(len(td_levels) - 1): + x2, y2, w2, h2, _ = td_levels[i] + x3, y3, w3, h3, _ = td_levels[i + 1] + draw_arrow( + ax, + x2 + w2 / 2, + y2, + x3 + w3 / 2, + y3 + h3 + 0.1, + lw=1.2, + style="->", + color=GRAY3, + ) + + ax.text( + 5.5, + -0.3, + "Top-down + lateral\n(FPN)", + ha="center", + fontsize=FS, + fontweight="bold", + ) + + # Detection outputs + det_labels = ["małe obj.", "średnie", "duże", "b. duże"] + for i, (x, y, w, h, _label) in enumerate(td_levels): + draw_arrow(ax, x + w + 0.1, y + h / 2, 7.5, y + h / 2, lw=0.8) + ax.text( + 7.7, + y + h / 2, + f"detekcja:\n{det_labels[3 - i]}", + fontsize=FS_SMALL, + va="center", + ) + + save_fig(fig, "q24_fpn.png") + + +# ============================================================ +# 17. Anchor boxes +# ============================================================ +def draw_anchor_boxes() -> None: + """Draw anchor boxes.""" + fig, ax = plt.subplots(figsize=(7, 5)) + ax.set_title( + "Anchor boxes — predefiniowane kształty", + fontsize=FS_TITLE, + fontweight="bold", + pad=12, + ) + + ax.add_patch(mpatches.Rectangle((0, 0), 6, 5, facecolor=GRAY4, edgecolor=LN, lw=1)) + + # Center point + cx, cy = 3, 2.5 + ax.plot(cx, cy, "ko", markersize=8, zorder=5) + ax.text(cx + 0.15, cy + 0.15, "(x, y)", fontsize=FS, fontweight="bold") + + # 9 anchors: 3 sizes x 3 ratios + anchors = [ + (0.8, 0.8, "-", "1:1 small"), + (1.6, 1.6, "-", "1:1 medium"), + (2.4, 2.4, "-", "1:1 large"), + (0.6, 1.2, "--", "1:2 small"), + (1.2, 2.4, "--", "1:2 medium"), + (1.8, 3.6, "--", "1:2 large"), + (1.2, 0.6, ":", "2:1 small"), + (2.4, 1.2, ":", "2:1 medium"), + (3.6, 1.8, ":", "2:1 large"), + ] + + for w, h, ls, _label in anchors: + rect = mpatches.Rectangle( + (cx - w / 2, cy - h / 2), + w, + h, + facecolor="none", + edgecolor=LN, + lw=1.2, + linestyle=ls, + ) + ax.add_patch(rect) + + # Legend-style labels + ax.text( + 3, + -0.5, + "9 anchorów = 3 rozmiary x 3 proporcje (1:1, 1:2, 2:1)\n" + "Sieć predykuje PRZESUNIĘCIE od najbliższego anchora", + ha="center", + fontsize=FS, + style="italic", + bbox={"boxstyle": "round,pad=0.3", "facecolor": GRAY4, "edgecolor": GRAY3}, + ) + + ax.set_xlim(-0.5, 6.5) + ax.set_ylim(-1.2, 5.5) + ax.set_aspect("equal") + ax.axis("off") + + save_fig(fig, "q24_anchor_boxes.png") + + +# ============================================================ +# 18. Detection task comparison +# ============================================================ +def draw_detection_tasks() -> None: + """Draw detection tasks.""" + fig, axes = plt.subplots(1, 3, figsize=(12, 4)) + fig.suptitle( + "Klasyfikacja vs Detekcja vs Segmentacja", + fontsize=FS_TITLE, + fontweight="bold", + y=1.02, + ) + + # Classification + ax = axes[0] + ax.add_patch( + mpatches.Rectangle((0, 0), 4, 4, facecolor=GRAY4, edgecolor=LN, lw=1.5) + ) + # Simple cat silhouette + ax.add_patch(mpatches.Ellipse((2, 2), 2, 1.5, facecolor=GRAY3, edgecolor=LN, lw=1)) + ax.add_patch(mpatches.Ellipse((2, 3), 1, 0.8, facecolor=GRAY3, edgecolor=LN, lw=1)) + # Ears + ax.plot([1.6, 1.5, 1.8], [3.3, 3.8, 3.4], color=LN, lw=1.5) + ax.plot([2.2, 2.5, 2.4], [3.3, 3.8, 3.4], color=LN, lw=1.5) + ax.text( + 2, -0.4, '→ "KOT" (jedna etykieta)', ha="center", fontsize=FS, fontweight="bold" + ) + ax.set_xlim(-0.5, 4.5) + ax.set_ylim(-0.8, 4.5) + ax.set_aspect("equal") + ax.axis("off") + ax.set_title("Klasyfikacja\n(co?)", fontsize=FS, fontweight="bold") + + # Detection + ax = axes[1] + ax.add_patch( + mpatches.Rectangle((0, 0), 4, 4, facecolor=GRAY4, edgecolor=LN, lw=1.5) + ) + # Cat + ax.add_patch( + mpatches.Ellipse((1.2, 2), 1.2, 1, facecolor=GRAY3, edgecolor=LN, lw=1) + ) + ax.add_patch( + mpatches.Ellipse((1.2, 2.8), 0.7, 0.5, facecolor=GRAY3, edgecolor=LN, lw=1) + ) + # Dog + ax.add_patch( + mpatches.Ellipse((3, 1.5), 1.2, 1, facecolor=GRAY2, edgecolor=LN, lw=1) + ) + ax.add_patch( + mpatches.Ellipse((3, 2.3), 0.7, 0.5, facecolor=GRAY2, edgecolor=LN, lw=1) + ) + # Bounding boxes + ax.add_patch( + mpatches.Rectangle((0.3, 1.2), 1.8, 2.2, facecolor="none", edgecolor=LN, lw=2.5) + ) + ax.text(1.2, 3.5, "KOT", ha="center", fontsize=FS_SMALL, fontweight="bold") + ax.add_patch( + mpatches.Rectangle((2.1, 0.8), 1.7, 2.0, facecolor="none", edgecolor=LN, lw=2.5) + ) + ax.text(3.0, 2.9, "PIES", ha="center", fontsize=FS_SMALL, fontweight="bold") + ax.text( + 2, + -0.4, + "→ bbox + klasa (N obiektów)", + ha="center", + fontsize=FS, + fontweight="bold", + ) + ax.set_xlim(-0.5, 4.5) + ax.set_ylim(-0.8, 4.5) + ax.set_aspect("equal") + ax.axis("off") + ax.set_title("Detekcja\n(co? + gdzie?)", fontsize=FS, fontweight="bold") + + # Segmentation + ax = axes[2] + ax.add_patch( + mpatches.Rectangle((0, 0), 4, 4, facecolor=GRAY4, edgecolor=LN, lw=1.5) + ) + # Cat mask (detailed) + theta = np.linspace(0, 2 * np.pi, 30) + cat_x = 1.2 + 0.6 * np.cos(theta) + 0.1 * np.sin(3 * theta) + cat_y = 2 + 0.5 * np.sin(theta) + 0.1 * np.cos(2 * theta) + ax.fill(cat_x, cat_y, facecolor=GRAY3, edgecolor=LN, lw=1.5) + # Dog mask + dog_x = 3.0 + 0.6 * np.cos(theta) + 0.05 * np.sin(4 * theta) + dog_y = 1.5 + 0.5 * np.sin(theta) + 0.08 * np.cos(3 * theta) + ax.fill(dog_x, dog_y, facecolor=GRAY2, edgecolor=LN, lw=1.5) + ax.text(1.2, 2, "KOT", ha="center", fontsize=FS_SMALL, fontweight="bold") + ax.text(3.0, 1.5, "PIES", ha="center", fontsize=FS_SMALL, fontweight="bold") + ax.text( + 2, + -0.4, + "→ maska pikseli (per piksel)", + ha="center", + fontsize=FS, + fontweight="bold", + ) + ax.set_xlim(-0.5, 4.5) + ax.set_ylim(-0.8, 4.5) + ax.set_aspect("equal") + ax.axis("off") + ax.set_title("Segmentacja\n(dokładna maska)", fontsize=FS, fontweight="bold") + + fig.tight_layout() + save_fig(fig, "q24_detection_tasks.png") + + +# ============================================================ +# 19. CNN Architecture overview +# ============================================================ +def draw_cnn_architecture() -> None: + """Draw cnn architecture.""" + fig, ax = plt.subplots(figsize=(12, 4)) + ax.set_xlim(-0.5, 12.5) + ax.set_ylim(-1, 4.5) + ax.set_aspect("equal") + ax.axis("off") + ax.set_title( + "CNN — od obrazu do predykcji (architektura)", + fontsize=FS_TITLE, + fontweight="bold", + pad=12, + ) + + # Input image + draw_box(ax, 0, 0.5, 1.5, 3, "Obraz\n224x224x3", fill=GRAY1, fontsize=FS) + + # Conv1 + draw_arrow(ax, 1.6, 2.0, 2.1, 2.0, lw=1.2) + draw_box( + ax, 2.2, 0.8, 1.2, 2.4, "Conv1\n+ReLU\n55x55x96", fill=GRAY4, fontsize=FS_SMALL + ) + + # Pool1 + draw_arrow(ax, 3.5, 2.0, 3.9, 2.0, lw=1.2) + draw_box(ax, 4.0, 1.0, 1.0, 2.0, "Pool\n27x27\nx96", fill=GRAY2, fontsize=FS_SMALL) + + # Conv2 + draw_arrow(ax, 5.1, 2.0, 5.5, 2.0, lw=1.2) + draw_box( + ax, + 5.6, + 0.8, + 1.2, + 2.4, + "Conv2\n+ReLU\n27x27\nx256", + fill=GRAY4, + fontsize=FS_SMALL, + ) + + # Pool2 + draw_arrow(ax, 6.9, 2.0, 7.3, 2.0, lw=1.2) + draw_box(ax, 7.4, 1.2, 0.8, 1.6, "Pool\n13x13\nx256", fill=GRAY2, fontsize=FS_SMALL) + + # More conv... + draw_arrow(ax, 8.3, 2.0, 8.7, 2.0, lw=1.2) + ax.text(9.0, 2.0, "...", fontsize=14, ha="center", va="center") + draw_arrow(ax, 9.3, 2.0, 9.7, 2.0, lw=1.2) + + # FC + draw_box(ax, 9.8, 1.2, 1.0, 1.6, "FC\n4096", fill=GRAY3, fontsize=FS) + + draw_arrow(ax, 10.9, 2.0, 11.3, 2.0, lw=1.2) + + # Output + draw_box( + ax, 11.4, 1.5, 1.0, 1.0, "Softmax\n1000 klas", fill=GRAY1, fontsize=FS_SMALL + ) + + # Annotations below + ax.text( + 3.0, + 0.0, + "rozmiar maleje\n224→55→27→13→6", + ha="center", + fontsize=FS_SMALL, + style="italic", + ) + ax.text( + 6.0, + 0.0, + "kanały rosną\n3→96→256→384", + ha="center", + fontsize=FS_SMALL, + style="italic", + ) + ax.text( + 10.0, 0.0, "decyzja\nkońcowa", ha="center", fontsize=FS_SMALL, style="italic" + ) + + # hierarchy + ax.text( + 6.0, + 4.0, + "Hierarchia: krawędzie → rogi → fragmenty → obiekty (K-R-F-O)", + ha="center", + fontsize=FS, + fontweight="bold", + bbox={"boxstyle": "round,pad=0.3", "facecolor": GRAY4, "edgecolor": GRAY3}, + ) + + save_fig(fig, "q24_cnn_architecture.png") diff --git a/python_pkg/praca_magisterska_video/generate_images/_q24_haar_integral_svm.py b/python_pkg/praca_magisterska_video/generate_images/_q24_haar_integral_svm.py new file mode 100644 index 0000000..1b35caf --- /dev/null +++ b/python_pkg/praca_magisterska_video/generate_images/_q24_haar_integral_svm.py @@ -0,0 +1,342 @@ +"""Haar features, integral image, and SVM hyperplane diagrams.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from _q24_common import ( + _DATA_BRIGHT_THRESH, + _II_BRIGHT_THRESH, + FS, + FS_LABEL, + FS_SMALL, + FS_TITLE, + GRAY3, + GRAY4, + LN, + np, + plt, + rng, + save_fig, +) +import matplotlib.patches as mpatches + +if TYPE_CHECKING: + from matplotlib.axes import Axes + + +# ============================================================ +# 4. Haar Features +# ============================================================ +def draw_haar_features() -> None: + """Draw haar features.""" + fig, axes = plt.subplots(1, 4, figsize=(11, 3)) + fig.suptitle( + "Cechy Haar — typy i zastosowanie na twarzy", + fontsize=FS_TITLE, + fontweight="bold", + y=1.02, + ) + + # Feature 1: Vertical edge + ax = axes[0] + ax.add_patch( + mpatches.Rectangle((0, 0), 1, 2, facecolor=GRAY4, edgecolor=LN, lw=1.5) + ) + ax.add_patch( + mpatches.Rectangle((1, 0), 1, 2, facecolor=GRAY3, edgecolor=LN, lw=1.5) + ) + ax.text( + 0.5, 1, "+Σ₁", ha="center", va="center", fontsize=FS_LABEL, fontweight="bold" + ) + ax.text( + 1.5, 1, "-Σ₂", ha="center", va="center", fontsize=FS_LABEL, fontweight="bold" + ) + ax.set_xlim(-0.2, 2.2) + ax.set_ylim(-0.5, 2.5) + ax.set_aspect("equal") + ax.axis("off") + ax.set_title("Krawędź pionowa\nwartość = Σ₁ - Σ₂", fontsize=FS) + + # Feature 2: Horizontal edge + ax = axes[1] + ax.add_patch( + mpatches.Rectangle((0, 1), 2, 1, facecolor=GRAY4, edgecolor=LN, lw=1.5) + ) + ax.add_patch( + mpatches.Rectangle((0, 0), 2, 1, facecolor=GRAY3, edgecolor=LN, lw=1.5) + ) + ax.text( + 1, 1.5, "+Σ₁", ha="center", va="center", fontsize=FS_LABEL, fontweight="bold" + ) + ax.text( + 1, 0.5, "-Σ₂", ha="center", va="center", fontsize=FS_LABEL, fontweight="bold" + ) + ax.set_xlim(-0.2, 2.2) + ax.set_ylim(-0.5, 2.5) + ax.set_aspect("equal") + ax.axis("off") + ax.set_title("Krawędź pozioma\n(oczy vs czoło)", fontsize=FS) + + # Feature 3: Three-rectangle (line) + ax = axes[2] + ax.add_patch( + mpatches.Rectangle((0, 0), 0.7, 2, facecolor=GRAY3, edgecolor=LN, lw=1.5) + ) + ax.add_patch( + mpatches.Rectangle((0.7, 0), 0.7, 2, facecolor=GRAY4, edgecolor=LN, lw=1.5) + ) + ax.add_patch( + mpatches.Rectangle((1.4, 0), 0.7, 2, facecolor=GRAY3, edgecolor=LN, lw=1.5) + ) + ax.text( + 0.35, 1, "-Σ₁", ha="center", va="center", fontsize=FS_SMALL, fontweight="bold" + ) + ax.text( + 1.05, 1, "+Σ₂", ha="center", va="center", fontsize=FS_SMALL, fontweight="bold" + ) + ax.text( + 1.75, 1, "-Σ₃", ha="center", va="center", fontsize=FS_SMALL, fontweight="bold" + ) + ax.set_xlim(-0.2, 2.3) + ax.set_ylim(-0.5, 2.5) + ax.set_aspect("equal") + ax.axis("off") + ax.set_title("Linia (3 prostokąty)\n(nos vs policzki)", fontsize=FS) + + _draw_haar_face_panel(axes[3]) + + fig.tight_layout() + save_fig(fig, "q24_haar_features.png") + + +def _draw_haar_face_panel(ax: Axes) -> None: + """Draw Haar feature application on face schematic.""" + face = mpatches.Ellipse( + (1.2, 1.2), + 2.0, + 2.4, + facecolor=GRAY4, + edgecolor=LN, + lw=1.5, + ) + ax.add_patch(face) + ax.add_patch( + mpatches.Ellipse((0.7, 1.6), 0.4, 0.2, facecolor=GRAY3, edgecolor=LN, lw=1) + ) + ax.add_patch( + mpatches.Ellipse((1.7, 1.6), 0.4, 0.2, facecolor=GRAY3, edgecolor=LN, lw=1) + ) + ax.plot([1.2, 1.1, 1.3], [1.3, 0.9, 0.9], color=LN, lw=1) + ax.plot([0.8, 1.0, 1.2, 1.4, 1.6], [0.55, 0.5, 0.55, 0.5, 0.55], color=LN, lw=1) + ax.add_patch( + mpatches.Rectangle( + (0.3, 1.4), + 1.8, + 0.4, + facecolor="none", + edgecolor=LN, + lw=2, + linestyle="--", + ) + ) + ax.annotate( + "cechy Haar\n(oczy ciemne\nvs czoło jasne)", + xy=(1.2, 1.85), + xytext=(2.2, 2.3), + fontsize=FS_SMALL, + ha="center", + arrowprops={"arrowstyle": "->", "color": LN, "lw": 1}, + ) + ax.set_xlim(-0.2, 3.0) + ax.set_ylim(-0.2, 2.8) + ax.set_aspect("equal") + ax.axis("off") + ax.set_title("Zastosowanie na twarzy", fontsize=FS) + + +# ============================================================ +# 5. Integral Image +# ============================================================ +def draw_integral_image() -> None: + """Draw integral image.""" + fig, axes = plt.subplots(1, 3, figsize=(11, 3.5)) + fig.suptitle( + "Integral Image — suma prostokąta w O(1)", + fontsize=FS_TITLE, + fontweight="bold", + y=1.02, + ) + + # Original image + ax = axes[0] + data = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]]) + ax.imshow(data, cmap="gray", vmin=0, vmax=10) + for i in range(3): + for j in range(3): + ax.text( + j, + i, + str(data[i, j]), + ha="center", + va="center", + fontsize=12, + fontweight="bold", + color="white" if data[i, j] > _DATA_BRIGHT_THRESH else "black", + ) + ax.set_title("① Obraz oryginalny", fontsize=FS, fontweight="bold") + ax.set_xticks([]) + ax.set_yticks([]) + + # Integral image + ax = axes[1] + ii = np.array([[1, 3, 6], [5, 12, 21], [12, 27, 45]]) + ax.imshow(ii, cmap="gray", vmin=0, vmax=50) + for i in range(3): + for j in range(3): + ax.text( + j, + i, + str(ii[i, j]), + ha="center", + va="center", + fontsize=12, + fontweight="bold", + color="white" if ii[i, j] > _II_BRIGHT_THRESH else "black", + ) + ax.set_title("② Integral Image\n(sumy kumulatywne)", fontsize=FS, fontweight="bold") + ax.set_xticks([]) + ax.set_yticks([]) + + # Formula illustration + ax = axes[2] + ax.axis("off") + ax.set_xlim(0, 4) + ax.set_ylim(0, 4) + # Draw rectangle + ax.add_patch( + mpatches.Rectangle((0.5, 0.5), 3, 3, facecolor="white", edgecolor=LN, lw=1) + ) + ax.add_patch( + mpatches.Rectangle((1.5, 0.5), 2, 2, facecolor=GRAY3, edgecolor=LN, lw=2) + ) + # Labels + ax.text(0.3, 3.7, "A", fontsize=12, fontweight="bold") + ax.text(3.6, 3.7, "B", fontsize=12, fontweight="bold") + ax.text(0.3, 0.3, "C", fontsize=12, fontweight="bold") + ax.text(3.6, 0.3, "D", fontsize=12, fontweight="bold") + ax.text( + 2.5, + 1.5, + "SZUKANA\nSUMA", + ha="center", + va="center", + fontsize=FS, + fontweight="bold", + ) + ax.text( + 2.0, + -0.3, + "Suma = D - B - C + A\n= 4 odczyty → O(1) ZAWSZE!", + ha="center", + fontsize=FS, + fontweight="bold", + bbox={"boxstyle": "round,pad=0.3", "facecolor": GRAY4, "edgecolor": GRAY3}, + ) + ax.set_title( + "③ Formuła: 4 odczyty\n= O(1) niezależnie od rozmiaru", + fontsize=FS, + fontweight="bold", + ) + + fig.tight_layout() + save_fig(fig, "q24_integral_image.png") + + +# ============================================================ +# 11. SVM Hyperplane +# ============================================================ +def draw_svm_hyperplane() -> None: + """Draw svm hyperplane.""" + fig, ax = plt.subplots(figsize=(6, 5)) + ax.set_title( + "SVM — hiperpłaszczyzna i margines", + fontsize=FS_TITLE, + fontweight="bold", + pad=12, + ) + + x_pos = rng.standard_normal(15) * 0.5 + 3 + y_pos = rng.standard_normal(15) * 0.5 + 3 + ax.scatter( + x_pos, + y_pos, + marker="o", + s=50, + facecolors="white", + edgecolors=LN, + linewidths=1.5, + label="klasa +1 (pieszy)", + zorder=3, + ) + + x_neg = rng.standard_normal(15) * 0.5 + 1 + y_neg = rng.standard_normal(15) * 0.5 + 1 + ax.scatter( + x_neg, + y_neg, + marker="x", + s=50, + c=LN, + linewidths=1.5, + label="klasa -1 (tło)", + zorder=3, + ) + + # Hyperplane (decision boundary) + x_line = np.linspace(-0.5, 5, 100) + y_line = -x_line + 4.0 + ax.plot(x_line, y_line, "k-", lw=2, label="hiperpłaszczyzna") + + # Margin lines + ax.plot(x_line, y_line + 0.7, "k--", lw=1, alpha=0.5) + ax.plot(x_line, y_line - 0.7, "k--", lw=1, alpha=0.5) + + # Margin annotation + ax.annotate( + "", + xy=(2.5, 1.5 + 0.7), + xytext=(2.5, 1.5 - 0.7), + arrowprops={"arrowstyle": "<->", "color": LN, "lw": 1.5}, + ) + ax.text(2.8, 1.5, "margines\n(MAX!)", fontsize=FS, fontweight="bold") + + # Support vectors (highlight closest points) + ax.scatter( + [2.5], + [2.2], + marker="o", + s=120, + facecolors="none", + edgecolors=LN, + linewidths=2.5, + zorder=4, + ) + ax.scatter([1.5], [1.8], marker="x", s=120, c=LN, linewidths=2.5, zorder=4) + ax.annotate( + "support\nvectors", + xy=(1.5, 1.8), + xytext=(0.2, 3.0), + fontsize=FS, + fontweight="bold", + arrowprops={"arrowstyle": "->", "color": LN, "lw": 1}, + ) + + ax.set_xlim(-0.5, 5) + ax.set_ylim(-0.5, 5) + ax.set_xlabel("cecha 1 (np. gradient pionowy)", fontsize=FS) + ax.set_ylabel("cecha 2 (np. gradient poziomy)", fontsize=FS) + ax.legend(fontsize=FS_SMALL, loc="lower right") + ax.set_aspect("equal") + + save_fig(fig, "q24_svm_hyperplane.png") diff --git a/python_pkg/praca_magisterska_video/generate_images/_q24_hog_classical.py b/python_pkg/praca_magisterska_video/generate_images/_q24_hog_classical.py new file mode 100644 index 0000000..0d53d6a --- /dev/null +++ b/python_pkg/praca_magisterska_video/generate_images/_q24_hog_classical.py @@ -0,0 +1,380 @@ +"""HOG + SVM pipeline, HOG gradient steps, Viola-Jones cascade.""" + +from __future__ import annotations + +from _q24_common import ( + _DOTS_STAGE_IDX, + _GRADIENT_BRIGHT_THRESH, + _PIXEL_BRIGHT_THRESH, + FS, + FS_LABEL, + FS_SMALL, + FS_TITLE, + GRAY1, + GRAY2, + GRAY3, + GRAY4, + GRAY5, + LN, + draw_arrow, + draw_box, + np, + plt, + save_fig, +) +import matplotlib.patches as mpatches + + +# ============================================================ +# 1. HOG + SVM Pipeline +# ============================================================ +def draw_hog_svm_pipeline() -> None: + """Draw hog svm pipeline.""" + fig, ax = plt.subplots(figsize=(10, 4.5)) + ax.set_xlim(-0.5, 10.5) + ax.set_ylim(-1, 4.5) + ax.set_aspect("equal") + ax.axis("off") + ax.set_title( + "HOG + SVM — pipeline detekcji pieszych", + fontsize=FS_TITLE, + fontweight="bold", + pad=12, + ) + + # Step 1: Image with sliding window + ax.add_patch( + mpatches.Rectangle((0, 1.5), 2, 2, lw=1.5, edgecolor=LN, facecolor=GRAY1) + ) + ax.text(1, 2.5, "Obraz\nwejściowy", ha="center", va="center", fontsize=FS) + # sliding window overlay + ax.add_patch( + mpatches.Rectangle( + (0.3, 1.8), + 0.8, + 1.2, + lw=1.5, + edgecolor="black", + facecolor="none", + linestyle="--", + ) + ) + ax.text( + 0.7, + 1.35, + "okno 64x128", + ha="center", + va="center", + fontsize=FS_SMALL, + style="italic", + ) + + draw_arrow(ax, 2.1, 2.5, 2.8, 2.5, lw=1.5) + ax.text(2.45, 2.75, "①", ha="center", fontsize=FS_LABEL, fontweight="bold") + + # Step 2: Gradient computation + draw_box( + ax, 2.9, 1.8, 1.6, 1.4, "Oblicz\ngradienty\nGx, Gy", fill=GRAY4, fontsize=FS + ) + ax.text( + 3.7, 1.55, "kierunek + siła", ha="center", fontsize=FS_SMALL, style="italic" + ) + + draw_arrow(ax, 4.6, 2.5, 5.2, 2.5, lw=1.5) + ax.text(4.9, 2.75, "②", ha="center", fontsize=FS_LABEL, fontweight="bold") + + # Step 3: HOG histogram + draw_box( + ax, + 5.3, + 1.8, + 1.6, + 1.4, + "Histogramy\nkierunkowe\n9 binów/cel", + fill=GRAY4, + fontsize=FS, + ) + ax.text(6.1, 1.55, "komórki 8x8 px", ha="center", fontsize=FS_SMALL, style="italic") + + draw_arrow(ax, 7.0, 2.5, 7.6, 2.5, lw=1.5) + ax.text(7.3, 2.75, "③", ha="center", fontsize=FS_LABEL, fontweight="bold") + + # Step 4: SVM + draw_box( + ax, + 7.7, + 1.8, + 1.4, + 1.4, + "SVM\nklasyfikator\npieszy/tło", + fill=GRAY3, + fontsize=FS, + fontweight="bold", + ) + + draw_arrow(ax, 9.2, 2.5, 9.7, 2.5, lw=1.5) + ax.text(9.45, 2.75, "④", ha="center", fontsize=FS_LABEL, fontweight="bold") + + # Step 5: NMS + output + draw_box(ax, 9.3, 2.0, 1.0, 1.0, "NMS\n→ wynik", fill=GRAY1, fontsize=FS) + + # Bottom: HOG feature vector illustration + ax.text( + 5.0, + 0.7, + "Wektor HOG: 3780 cech = 105 bloków x 4 komórki x 9 binów", + ha="center", + fontsize=FS, + style="italic", + bbox={"boxstyle": "round,pad=0.3", "facecolor": GRAY4, "edgecolor": GRAY3}, + ) + + # Show small histogram bars + bar_x = 3.2 + bar_y = 0.0 + angles = [0, 20, 40, 60, 80, 100, 120, 140, 160] + values = [0.3, 0.1, 0.5, 0.8, 0.2, 0.6, 0.15, 0.4, 0.25] + for i, (_a, v) in enumerate(zip(angles, values, strict=False)): + ax.add_patch( + mpatches.Rectangle( + (bar_x + i * 0.18, bar_y), + 0.15, + v * 0.6, + facecolor=GRAY3, + edgecolor=LN, + lw=0.5, + ) + ) + ax.text(bar_x + 0.8, -0.2, "9 binów (0°-160°)", ha="center", fontsize=FS_SMALL) + + save_fig(fig, "q24_hog_svm_pipeline.png") + + +# ============================================================ +# 2. HOG Gradient Step-by-Step +# ============================================================ +def draw_hog_gradient_steps() -> None: + """Draw hog gradient steps.""" + fig, axes = plt.subplots(1, 4, figsize=(12, 3.5)) + fig.suptitle( + "HOG — kroki obliczania cech", fontsize=FS_TITLE, fontweight="bold", y=1.02 + ) + + # Step 1: Original patch + ax = axes[0] + patch = np.array([[50, 50, 200], [50, 50, 200], [50, 50, 200]]) + ax.imshow(patch, cmap="gray", vmin=0, vmax=255) + for i in range(3): + for j in range(3): + ax.text( + j, + i, + str(patch[i, j]), + ha="center", + va="center", + fontsize=FS_LABEL, + fontweight="bold", + color="white" if patch[i, j] > _PIXEL_BRIGHT_THRESH else "black", + ) + ax.set_title("① Fragment obrazu\n(jasność pikseli)", fontsize=FS, fontweight="bold") + ax.set_xticks([]) + ax.set_yticks([]) + + # Step 2: Gradient magnitude + ax = axes[1] + gx = np.array([[0, 150, 0], [0, 150, 0], [0, 150, 0]]) + ax.imshow(gx, cmap="gray", vmin=0, vmax=255) + for i in range(3): + for j in range(3): + ax.text( + j, + i, + str(gx[i, j]), + ha="center", + va="center", + fontsize=FS_LABEL, + fontweight="bold", + color="white" if gx[i, j] > _GRADIENT_BRIGHT_THRESH else "black", + ) + ax.set_title("② Gradient Gx\n(krawędź pionowa!)", fontsize=FS, fontweight="bold") + ax.set_xticks([]) + ax.set_yticks([]) + + # Step 3: Cell histogram + ax = axes[2] + angles = ["0°", "20°", "40°", "60°", "80°", "100°", "120°", "140°", "160°"] + values = [150, 0, 0, 0, 0, 0, 0, 0, 0] + bars = ax.bar(range(9), values, color=GRAY3, edgecolor=LN, linewidth=0.5) + bars[0].set_facecolor(GRAY5) + ax.set_xticks(range(9)) + ax.set_xticklabels(angles, fontsize=5, rotation=45) + ax.set_title( + "③ Histogram komórki\n(bin 0° = krawędź pionowa)", + fontsize=FS, + fontweight="bold", + ) + ax.set_ylabel("siła", fontsize=FS_SMALL) + + # Step 4: Block normalization + ax = axes[3] + # 2x2 block of cells + for i in range(2): + for j in range(2): + rect = mpatches.Rectangle( + (j * 1.2, (1 - i) * 1.2), + 1.0, + 1.0, + lw=1.2, + edgecolor=LN, + facecolor=GRAY4, + ) + ax.add_patch(rect) + ax.text( + j * 1.2 + 0.5, + (1 - i) * 1.2 + 0.5, + f"hist\n{i * 2 + j + 1}", + ha="center", + va="center", + fontsize=FS_SMALL, + ) + ax.add_patch( + mpatches.Rectangle( + (-0.1, -0.1), 2.6, 2.6, lw=2, edgecolor=LN, facecolor="none", linestyle="--" + ) + ) + ax.text( + 1.2, + -0.4, + "blok 2x2 → L2-norm", + ha="center", + fontsize=FS_SMALL, + fontweight="bold", + ) + ax.set_xlim(-0.3, 2.8) + ax.set_ylim(-0.7, 2.8) + ax.set_aspect("equal") + ax.axis("off") + ax.set_title( + "④ Normalizacja bloków\n(odporność na oświetlenie)", + fontsize=FS, + fontweight="bold", + ) + + fig.tight_layout() + save_fig(fig, "q24_hog_gradient_steps.png") + + +# ============================================================ +# 3. Viola-Jones Cascade +# ============================================================ +def draw_viola_jones_cascade() -> None: + """Draw viola jones cascade.""" + fig, ax = plt.subplots(figsize=(10, 5)) + ax.set_xlim(-0.5, 10.5) + ax.set_ylim(-1.5, 5) + ax.set_aspect("equal") + ax.axis("off") + ax.set_title( + "Viola-Jones — kaskada klasyfikatorów (SITO)", + fontsize=FS_TITLE, + fontweight="bold", + pad=12, + ) + + # Input + draw_box( + ax, + -0.3, + 2.5, + 1.5, + 1.2, + "500 000\nokien", + fill=GRAY1, + fontsize=FS, + fontweight="bold", + ) + + stages = [ + ("Etap 1\n2 cechy", "50%\nodrzucone", "250 000", GRAY4), + ("Etap 2\n10 cech", "80%\nodrzucone", "50 000", GRAY4), + ("Etap 3\n25 cech", "90%\nodrzucone", "5 000", GRAY4), + ("Etap 25\n200 cech", "99%\nodrzucone", "50", GRAY3), + ] + + x_pos = 1.6 + for i, (label, reject, remain, col) in enumerate(stages): + # Stage box + draw_box( + ax, x_pos, 2.5, 1.6, 1.2, label, fill=col, fontsize=FS, fontweight="bold" + ) + + # Arrow from previous + draw_arrow(ax, x_pos - 0.3, 3.1, x_pos - 0.05, 3.1, lw=1.5) + + # Reject arrow down + draw_arrow(ax, x_pos + 0.8, 2.45, x_pos + 0.8, 1.6, lw=1.2) + ax.text( + x_pos + 0.8, + 1.3, + reject, + ha="center", + fontsize=FS_SMALL, + color="black", + style="italic", + ) + ax.text( + x_pos + 0.8, + 0.8, + "✗ NIE-TWARZ", + ha="center", + fontsize=FS_SMALL, + fontweight="bold", + ) + + # Remaining count above + if i < len(stages) - 1: + ax.text( + x_pos + 2.0, + 3.9, + f"→ {remain}", + ha="center", + fontsize=FS_SMALL, + style="italic", + ) + + # Dots between stage 3 and stage 25 + if i == _DOTS_STAGE_IDX: + ax.text( + x_pos + 2.0, 3.1, "· · ·", ha="center", fontsize=12, fontweight="bold" + ) + x_pos += 2.5 + else: + x_pos += 2.1 + + # Final output + draw_arrow(ax, x_pos + 0.3, 3.1, x_pos + 0.9, 3.1, lw=1.5) + draw_box( + ax, + x_pos + 0.5, + 2.5, + 1.3, + 1.2, + "~50\nTWARZE\n✓", + fill=GRAY2, + fontsize=FS, + fontweight="bold", + ) + + # Timing info + ax.text( + 5.0, + -0.5, + "Czas: 99% okien odrzucone w etapach 1-3 (~5 μs każde)\n" + "Tylko 0.01% dochodzi do etapu 25 → cały obraz w ~30 ms = 30+ fps", + ha="center", + fontsize=FS, + style="italic", + bbox={"boxstyle": "round,pad=0.4", "facecolor": GRAY4, "edgecolor": GRAY3}, + ) + + save_fig(fig, "q24_viola_jones_cascade.png") diff --git a/python_pkg/praca_magisterska_video/generate_images/_q24_iou_nms_detector.py b/python_pkg/praca_magisterska_video/generate_images/_q24_iou_nms_detector.py new file mode 100644 index 0000000..4871d9f --- /dev/null +++ b/python_pkg/praca_magisterska_video/generate_images/_q24_iou_nms_detector.py @@ -0,0 +1,413 @@ +"""IoU diagram, NMS steps, and detector-from-classifier diagrams.""" + +from __future__ import annotations + +from _q24_common import ( + FS, + FS_LABEL, + FS_SMALL, + FS_TITLE, + GRAY1, + GRAY2, + GRAY3, + GRAY4, + LN, + draw_arrow, + draw_box, + plt, + save_fig, +) +import matplotlib.patches as mpatches + + +# ============================================================ +# 8. IoU Diagram +# ============================================================ +def draw_iou_diagram() -> None: + """Draw iou diagram.""" + fig, axes = plt.subplots(1, 3, figsize=(11, 3.5)) + fig.suptitle( + "IoU (Intersection over Union) — miara nakładania bboxów", + fontsize=FS_TITLE, + fontweight="bold", + y=1.02, + ) + + # Low IoU + ax = axes[0] + ax.add_patch( + mpatches.Rectangle( + (0, 0), 3, 3, facecolor=GRAY4, edgecolor=LN, lw=1.5, label="A" + ) + ) + ax.add_patch( + mpatches.Rectangle( + (2.5, 2.5), + 3, + 3, + facecolor=GRAY2, + edgecolor=LN, + lw=1.5, + alpha=0.7, + label="B", + ) + ) + # Intersection + ax.add_patch( + mpatches.Rectangle((2.5, 2.5), 0.5, 0.5, facecolor=GRAY3, edgecolor=LN, lw=2) + ) + ax.text(1.5, 1.5, "A", ha="center", va="center", fontsize=12, fontweight="bold") + ax.text(4, 4, "B", ha="center", va="center", fontsize=12, fontweight="bold") + ax.set_xlim(-0.5, 6) + ax.set_ylim(-0.5, 6) + ax.set_aspect("equal") + ax.axis("off") + ax.set_title( + "IoU ≈ 0.04\n(prawie się nie nakładają)", fontsize=FS, fontweight="bold" + ) + + # Medium IoU + ax = axes[1] + ax.add_patch( + mpatches.Rectangle((0, 0), 3, 3, facecolor=GRAY4, edgecolor=LN, lw=1.5) + ) + ax.add_patch( + mpatches.Rectangle( + (1.5, 1.5), 3, 3, facecolor=GRAY2, edgecolor=LN, lw=1.5, alpha=0.7 + ) + ) + ax.add_patch( + mpatches.Rectangle((1.5, 1.5), 1.5, 1.5, facecolor=GRAY3, edgecolor=LN, lw=2) + ) + ax.text(0.7, 0.7, "A", ha="center", va="center", fontsize=12, fontweight="bold") + ax.text(3.5, 3.5, "B", ha="center", va="center", fontsize=12, fontweight="bold") + ax.text(2.25, 2.25, "∩", ha="center", va="center", fontsize=14, fontweight="bold") + ax.set_xlim(-0.5, 5) + ax.set_ylim(-0.5, 5) + ax.set_aspect("equal") + ax.axis("off") + ax.set_title("IoU ≈ 0.14\n(częściowe nakładanie)", fontsize=FS, fontweight="bold") + + # High IoU + ax = axes[2] + ax.add_patch( + mpatches.Rectangle((0, 0), 3, 3, facecolor=GRAY4, edgecolor=LN, lw=1.5) + ) + ax.add_patch( + mpatches.Rectangle( + (0.3, 0.3), 3, 3, facecolor=GRAY2, edgecolor=LN, lw=1.5, alpha=0.7 + ) + ) + ax.add_patch( + mpatches.Rectangle((0.3, 0.3), 2.7, 2.7, facecolor=GRAY3, edgecolor=LN, lw=2) + ) + ax.text(-0.3, -0.3, "A", ha="center", va="center", fontsize=12, fontweight="bold") + ax.text(3.5, 3.5, "B", ha="center", va="center", fontsize=12, fontweight="bold") + ax.text(1.65, 1.65, "∩", ha="center", va="center", fontsize=14, fontweight="bold") + ax.set_xlim(-0.8, 4) + ax.set_ylim(-0.8, 4) + ax.set_aspect("equal") + ax.axis("off") + ax.set_title( + "IoU ≈ 0.74\n(duże nakładanie → duplikat!)", fontsize=FS, fontweight="bold" + ) + + fig.tight_layout() + save_fig(fig, "q24_iou_diagram.png") + + +# ============================================================ +# 9. NMS Step-by-Step +# ============================================================ +def draw_nms_steps() -> None: + """Draw nms steps.""" + fig, axes = plt.subplots(1, 3, figsize=(12, 4)) + fig.suptitle( + "NMS (Non-Maximum Suppression) — usuwanie duplikatów", + fontsize=FS_TITLE, + fontweight="bold", + y=1.02, + ) + + # Before NMS + ax = axes[0] + ax.add_patch(mpatches.Rectangle((0, 0), 6, 5, facecolor=GRAY4, edgecolor=LN, lw=1)) + # Multiple overlapping boxes for same object + ax.add_patch( + mpatches.Rectangle((1, 1), 2.5, 3, facecolor="none", edgecolor=LN, lw=2) + ) + ax.text(2.25, 4.2, "conf=0.95", ha="center", fontsize=FS_SMALL, fontweight="bold") + ax.add_patch( + mpatches.Rectangle( + (1.2, 1.3), 2.3, 2.8, facecolor="none", edgecolor=LN, lw=1.5, linestyle="--" + ) + ) + ax.text(2.35, 1.1, "conf=0.90", ha="center", fontsize=FS_SMALL) + ax.add_patch( + mpatches.Rectangle( + (0.8, 0.8), 2.7, 3.2, facecolor="none", edgecolor=LN, lw=1, linestyle=":" + ) + ) + ax.text(2.15, 0.6, "conf=0.85", ha="center", fontsize=FS_SMALL) + # Different object + ax.add_patch( + mpatches.Rectangle((4, 2), 1.5, 1.5, facecolor="none", edgecolor=LN, lw=1.5) + ) + ax.text(4.75, 3.7, "conf=0.80", ha="center", fontsize=FS_SMALL) + ax.text( + 2, + 0.2, + "⚠ 4 detekcje (3 duplikaty!)", + ha="center", + fontsize=FS_SMALL, + fontweight="bold", + ) + ax.set_xlim(-0.3, 6.3) + ax.set_ylim(-0.3, 5.3) + ax.set_aspect("equal") + ax.axis("off") + ax.set_title( + "① Przed NMS\n(wiele nakładających się)", fontsize=FS, fontweight="bold" + ) + + # NMS process + ax = axes[1] + ax.axis("off") + ax.set_xlim(0, 6) + ax.set_ylim(0, 5) + + steps = [ + ("1. Sortuj: [0.95, 0.90, 0.85, 0.80]", 4.5), + ("2. Weź najlepszą (0.95) → ZACHOWAJ", 3.7), + ("3. IoU(0.95, 0.90)=0.82 > 0.5 → USUŃ", 2.9), + ("4. IoU(0.95, 0.85)=0.75 > 0.5 → USUŃ", 2.1), + ("5. IoU(0.95, 0.80)=0.10 < 0.5 → ZACHOWAJ", 1.3), + ] + colors = [GRAY4, GRAY2, GRAY4, GRAY4, GRAY2] + for (text, yp), c in zip(steps, colors, strict=False): + ax.text( + 3.0, + yp, + text, + ha="center", + fontsize=FS, + bbox={"boxstyle": "round,pad=0.2", "facecolor": c, "edgecolor": GRAY3}, + ) + + ax.set_title("② Algorytm NMS\n(próg IoU = 0.5)", fontsize=FS, fontweight="bold") + + # After NMS + ax = axes[2] + ax.add_patch(mpatches.Rectangle((0, 0), 6, 5, facecolor=GRAY4, edgecolor=LN, lw=1)) + # Only best box for each object + ax.add_patch( + mpatches.Rectangle((1, 1), 2.5, 3, facecolor="none", edgecolor=LN, lw=2.5) + ) + ax.text(2.25, 4.2, "conf=0.95 ✓", ha="center", fontsize=FS_SMALL, fontweight="bold") + ax.add_patch( + mpatches.Rectangle((4, 2), 1.5, 1.5, facecolor="none", edgecolor=LN, lw=2.5) + ) + ax.text(4.75, 3.7, "conf=0.80 ✓", ha="center", fontsize=FS_SMALL, fontweight="bold") + ax.text( + 3, + 0.2, + "✓ 2 unikalne obiekty", + ha="center", + fontsize=FS_SMALL, + fontweight="bold", + ) + ax.set_xlim(-0.3, 6.3) + ax.set_ylim(-0.3, 5.3) + ax.set_aspect("equal") + ax.axis("off") + ax.set_title("③ Po NMS\n(1 bbox na obiekt)", fontsize=FS, fontweight="bold") + + fig.tight_layout() + save_fig(fig, "q24_nms_steps.png") + + +# ============================================================ +# 10. Detector from Classifier — 3 approaches +# ============================================================ +def draw_detector_from_classifier() -> None: + """Draw detector from classifier.""" + fig, ax = plt.subplots(figsize=(11, 9)) + ax.set_xlim(-0.5, 11) + ax.set_ylim(-1, 9.5) + ax.set_aspect("equal") + ax.axis("off") + ax.set_title( + "Jak zbudować detektor z klasyfikatora? — 3 podejścia", + fontsize=FS_TITLE, + fontweight="bold", + pad=12, + ) + + # ---- Approach 1: Sliding Window ---- + y = 7.0 + ax.text( + 0, + y + 1.5, + "① Sliding Window (NAJWOLNIEJSZE)", + fontsize=FS_LABEL, + fontweight="bold", + bbox={"boxstyle": "round,pad=0.2", "facecolor": GRAY4, "edgecolor": GRAY3}, + ) + + # Image with sliding window + ax.add_patch( + mpatches.Rectangle( + (0, y - 0.6), 1.8, 1.8, facecolor=GRAY1, edgecolor=LN, lw=1.5 + ) + ) + ax.text(0.9, y + 0.3, "obraz", ha="center", fontsize=FS_SMALL) + # Sliding windows + for dx, dy in [(0.1, 0.1), (0.4, 0.1), (0.7, 0.1), (0.1, 0.5), (0.4, 0.5)]: + ax.add_patch( + mpatches.Rectangle( + (dx, y - 0.5 + dy), + 0.5, + 0.5, + facecolor="none", + edgecolor=LN, + lw=0.8, + linestyle="--", + ) + ) + + draw_arrow(ax, 2.0, y + 0.3, 2.7, y + 0.3, lw=1.2) + ax.text(2.35, y + 0.6, "xmiliony", fontsize=FS_SMALL, style="italic") + + draw_box( + ax, + 2.8, + y - 0.3, + 1.8, + 1.2, + 'Klasyfikator\n(ResNet)\n"kot? pies? tło?"', + fill=GRAY4, + fontsize=FS, + ) + draw_arrow(ax, 4.7, y + 0.3, 5.3, y + 0.3, lw=1.2) + draw_box(ax, 5.4, y - 0.3, 1.2, 1.2, "NMS", fill=GRAY1, fontsize=FS) + draw_arrow(ax, 6.7, y + 0.3, 7.3, y + 0.3, lw=1.2) + ax.text( + 8.5, + y + 0.3, + "~3.3h / obraz!\n⚠ NIEPRAKTYCZNE", + ha="center", + fontsize=FS, + fontweight="bold", + bbox={"boxstyle": "round,pad=0.3", "facecolor": GRAY4, "edgecolor": GRAY3}, + ) + + # ---- Approach 2: Region Proposals ---- + y = 3.8 + ax.text( + 0, + y + 1.5, + "② Region Proposals + Klasyfikator (= R-CNN)", + fontsize=FS_LABEL, + fontweight="bold", + bbox={"boxstyle": "round,pad=0.2", "facecolor": GRAY4, "edgecolor": GRAY3}, + ) + + ax.add_patch( + mpatches.Rectangle( + (0, y - 0.6), 1.8, 1.8, facecolor=GRAY1, edgecolor=LN, lw=1.5 + ) + ) + ax.text(0.9, y + 0.3, "obraz", ha="center", fontsize=FS_SMALL) + # A few smart regions + ax.add_patch( + mpatches.Rectangle( + (0.1, y - 0.4), 0.7, 0.9, facecolor="none", edgecolor=LN, lw=1.5 + ) + ) + ax.add_patch( + mpatches.Rectangle( + (0.9, y + 0.0), 0.7, 0.6, facecolor="none", edgecolor=LN, lw=1.5 + ) + ) + + draw_arrow(ax, 2.0, y + 0.3, 2.7, y + 0.3, lw=1.2) + draw_box( + ax, + 2.8, + y - 0.3, + 1.6, + 1.2, + "Selective\nSearch\n~2000 regionów", + fill=GRAY2, + fontsize=FS, + ) + draw_arrow(ax, 4.5, y + 0.3, 5.1, y + 0.3, lw=1.2) + ax.text(4.8, y + 0.6, "x2000", fontsize=FS_SMALL, style="italic") + draw_box(ax, 5.2, y - 0.3, 1.5, 1.2, "Klasyfikator\n(CNN)", fill=GRAY4, fontsize=FS) + draw_arrow(ax, 6.8, y + 0.3, 7.4, y + 0.3, lw=1.2) + draw_box(ax, 7.5, y - 0.3, 1.0, 1.2, "NMS", fill=GRAY1, fontsize=FS) + draw_arrow(ax, 8.6, y + 0.3, 9.0, y + 0.3, lw=1.2) + ax.text( + 10.0, + y + 0.3, + "~20-50 s/obraz\n(250x szybciej)", + ha="center", + fontsize=FS, + fontweight="bold", + bbox={"boxstyle": "round,pad=0.3", "facecolor": GRAY4, "edgecolor": GRAY3}, + ) + + # ---- Approach 3: Fine-tune backbone ---- + y = 0.5 + ax.text( + 0, + y + 1.5, + "③ Fine-tune backbone + detection head (NAJLEPSZE)", + fontsize=FS_LABEL, + fontweight="bold", + bbox={"boxstyle": "round,pad=0.2", "facecolor": GRAY2, "edgecolor": GRAY3}, + ) + + ax.add_patch( + mpatches.Rectangle( + (0, y - 0.6), 1.8, 1.8, facecolor=GRAY1, edgecolor=LN, lw=1.5 + ) + ) + ax.text(0.9, y + 0.3, "obraz", ha="center", fontsize=FS_SMALL) + + draw_arrow(ax, 2.0, y + 0.3, 2.7, y + 0.3, lw=1.2) + draw_box( + ax, + 2.8, + y - 0.3, + 1.8, + 1.2, + "Pretrained\nbackbone\n(ResNet)", + fill=GRAY3, + fontsize=FS, + fontweight="bold", + ) + draw_arrow(ax, 4.7, y + 0.3, 5.3, y + 0.3, lw=1.2) + + # Two heads from feature map + draw_box(ax, 5.4, y + 0.3, 1.6, 0.6, "cls head\nP(klasa)", fill=GRAY4, fontsize=FS) + draw_box( + ax, 5.4, y - 0.5, 1.6, 0.6, "bbox head\nΔx,Δy,Δw,Δh", fill=GRAY4, fontsize=FS + ) + + draw_arrow(ax, 7.1, y + 0.6, 7.7, y + 0.6, lw=1.0) + draw_arrow(ax, 7.1, y - 0.2, 7.7, y - 0.2, lw=1.0) + draw_box(ax, 7.8, y - 0.3, 1.0, 1.2, "NMS", fill=GRAY1, fontsize=FS) + + draw_arrow(ax, 8.9, y + 0.3, 9.3, y + 0.3, lw=1.2) + ax.text( + 10.2, + y + 0.3, + "5-155 fps!\n✓ NAJLEPSZE", + ha="center", + fontsize=FS, + fontweight="bold", + bbox={"boxstyle": "round,pad=0.3", "facecolor": GRAY2, "edgecolor": GRAY3}, + ) + + save_fig(fig, "q24_detector_from_classifier.png") diff --git a/python_pkg/praca_magisterska_video/generate_images/_q24_modern_pipelines.py b/python_pkg/praca_magisterska_video/generate_images/_q24_modern_pipelines.py new file mode 100644 index 0000000..4a1584e --- /dev/null +++ b/python_pkg/praca_magisterska_video/generate_images/_q24_modern_pipelines.py @@ -0,0 +1,365 @@ +"""Two-stage vs one-stage table, ROI pooling, DETR, and sliding window.""" + +from __future__ import annotations + +from _q24_common import ( + _DATA_BRIGHT_THRESH, + FS, + FS_SMALL, + FS_TITLE, + GRAY1, + GRAY2, + GRAY3, + GRAY4, + GRAY5, + LN, + draw_arrow, + draw_box, + draw_table, + np, + plt, + rng, + save_fig, +) +import matplotlib.patches as mpatches + + +# ============================================================ +# 12. Two-stage vs One-stage comparison table +# ============================================================ +def draw_two_vs_one_stage() -> None: + """Draw two vs one stage.""" + fig, ax = plt.subplots(figsize=(10, 3.5)) + ax.set_xlim(0, 10) + ax.set_ylim(-0.5, 4.5) + ax.set_aspect("equal") + ax.axis("off") + ax.set_title( + "Two-stage vs One-stage — porównanie", + fontsize=FS_TITLE, + fontweight="bold", + pad=8, + ) + + headers = ["Cecha", "Two-stage\n(Faster R-CNN)", "One-stage\n(YOLO)"] + rows = [ + ["Szybkość", "~5 fps", "45-155 fps"], + ["Dokładność (mAP)", "wyższa (historycznie)", "dorównuje (YOLOv8)"], + ["Małe obiekty", "lepszy", "gorszy (SSD/FPN pomaga)"], + ["Architektura", "2 etapy + NMS", "1 etap + NMS"], + ["Real-time?", "NIE", "TAK"], + ] + col_widths = [2.5, 3.5, 3.5] + draw_table( + ax, + headers, + rows, + 0.2, + 3.8, + col_widths, + row_h=0.65, + fontsize=FS, + header_fontsize=FS, + ) + + save_fig(fig, "q24_two_vs_one_stage.png") + + +# ============================================================ +# 13. ROI Pooling illustration +# ============================================================ +def draw_roi_pooling() -> None: + """Draw roi pooling.""" + fig, axes = plt.subplots(1, 3, figsize=(12, 4)) + fig.suptitle( + "ROI Pooling — dowolny rozmiar → stały rozmiar", + fontsize=FS_TITLE, + fontweight="bold", + y=1.02, + ) + + # Feature map with ROI + ax = axes[0] + # Draw feature map grid + fm = rng.integers(0, 10, (8, 8)) + ax.imshow(fm, cmap="gray", vmin=0, vmax=10, alpha=0.3) + for i in range(9): + ax.axhline(y=i - 0.5, color=LN, lw=0.3) + ax.axvline(x=i - 0.5, color=LN, lw=0.3) + # ROI rectangle + ax.add_patch( + mpatches.Rectangle( + (1.5, 1.5), 4, 4, facecolor="none", edgecolor=LN, lw=3, linestyle="-" + ) + ) + ax.text(3.5, 0.8, "ROI", ha="center", fontsize=FS, fontweight="bold") + ax.set_xlim(-0.5, 7.5) + ax.set_ylim(7.5, -0.5) + ax.set_title("① Feature map\nz zaznaczonym ROI", fontsize=FS, fontweight="bold") + ax.set_xticks([]) + ax.set_yticks([]) + + # ROI divided into grid + ax = axes[1] + roi_data = np.array( + [ + [1, 3, 2, 1], + [0, 5, 1, 6], + [0, 4, 1, 0], + [7, 2, 9, 1], + ] + ) + ax.imshow(roi_data, cmap="gray", vmin=0, vmax=10) + for i in range(5): + ax.axhline(y=i - 0.5, color=LN, lw=1) + ax.axvline(x=i - 0.5, color=LN, lw=1) + # Grid lines for 2x2 pooling + ax.axhline(y=0.5, color=LN, lw=3, linestyle="--") + ax.axvline(x=0.5, color=LN, lw=3, linestyle="--") + for i in range(4): + for j in range(4): + ax.text( + j, + i, + str(roi_data[i, j]), + ha="center", + va="center", + fontsize=10, + fontweight="bold", + color="white" if roi_data[i, j] > _DATA_BRIGHT_THRESH else "black", + ) + ax.set_title("② ROI podzielony\nna siatkę 2x2", fontsize=FS, fontweight="bold") + ax.set_xticks([]) + ax.set_yticks([]) + + # Output after pooling + ax = axes[2] + out = np.array([[5, 6], [7, 9]]) + ax.imshow(out, cmap="gray", vmin=0, vmax=10) + for i in range(3): + ax.axhline(y=i - 0.5, color=LN, lw=1.5) + ax.axvline(x=i - 0.5, color=LN, lw=1.5) + for i in range(2): + for j in range(2): + ax.text( + j, + i, + str(out[i, j]), + ha="center", + va="center", + fontsize=14, + fontweight="bold", + color="white" if out[i, j] > _DATA_BRIGHT_THRESH else "black", + ) + ax.set_title( + "③ Po ROI Pool 2x2\n(max z każdej komórki)", fontsize=FS, fontweight="bold" + ) + ax.set_xticks([]) + ax.set_yticks([]) + + fig.tight_layout() + save_fig(fig, "q24_roi_pooling.png") + + +# ============================================================ +# 14. DETR Pipeline +# ============================================================ +def draw_detr_pipeline() -> None: + """Draw detr pipeline.""" + fig, ax = plt.subplots(figsize=(11, 4.5)) + ax.set_xlim(-0.5, 11.5) + ax.set_ylim(-1, 4.5) + ax.set_aspect("equal") + ax.axis("off") + ax.set_title( + "DETR — Transformer do detekcji (bez NMS, bez anchorów)", + fontsize=FS_TITLE, + fontweight="bold", + pad=12, + ) + + # Pipeline + draw_box(ax, 0, 1.5, 1.5, 1.5, "Obraz\nwejściowy", fill=GRAY1, fontsize=FS) + draw_arrow(ax, 1.6, 2.25, 2.1, 2.25, lw=1.5) + + draw_box( + ax, + 2.2, + 1.5, + 1.5, + 1.5, + "CNN\nBackbone\n(ResNet)", + fill=GRAY3, + fontsize=FS, + fontweight="bold", + ) + draw_arrow(ax, 3.8, 2.25, 4.3, 2.25, lw=1.5) + + draw_box( + ax, + 4.4, + 1.5, + 1.8, + 1.5, + "Transformer\nEncoder\n(self-attention)", + fill=GRAY2, + fontsize=FS, + ) + draw_arrow(ax, 6.3, 2.25, 6.8, 2.25, lw=1.5) + + draw_box( + ax, + 6.9, + 1.5, + 1.8, + 1.5, + "Transformer\nDecoder\n(N=100 queries)", + fill=GRAY2, + fontsize=FS, + fontweight="bold", + ) + + # Output branches + draw_arrow(ax, 8.8, 2.5, 9.5, 3.0, lw=1.2) + draw_box(ax, 9.6, 2.7, 1.5, 0.7, "klasa₁...klasa₁₀₀", fill=GRAY4, fontsize=FS_SMALL) + + draw_arrow(ax, 8.8, 2.0, 9.5, 1.5, lw=1.2) + draw_box(ax, 9.6, 1.2, 1.5, 0.7, "bbox₁...bbox₁₀₀", fill=GRAY4, fontsize=FS_SMALL) + + # Annotations + ax.text( + 7.8, + 0.5, + '100 object queries → 5 obiektów + 95x "brak"', + ha="center", + fontsize=FS, + style="italic", + bbox={"boxstyle": "round,pad=0.3", "facecolor": GRAY4, "edgecolor": GRAY3}, + ) + + ax.text( + 5.5, + 0.0, + "Hungarian matching (trening): optymalne dopasowanie predykcji do GT", + ha="center", + fontsize=FS_SMALL, + style="italic", + bbox={"boxstyle": "round,pad=0.2", "facecolor": GRAY4, "edgecolor": GRAY5}, + ) + + # Big benefit box + ax.text( + 5.5, + 4.0, + "BEZ anchorów • BEZ NMS • end-to-end • prosty pipeline", + ha="center", + fontsize=FS, + fontweight="bold", + bbox={"boxstyle": "round,pad=0.3", "facecolor": GRAY2, "edgecolor": GRAY3}, + ) + + save_fig(fig, "q24_detr_pipeline.png") + + +# ============================================================ +# 15. Sliding Window illustration +# ============================================================ +def draw_sliding_window() -> None: + """Draw sliding window.""" + fig, axes = plt.subplots(1, 3, figsize=(12, 4)) + fig.suptitle( + "Sliding Window — najprostsze podejście do detekcji", + fontsize=FS_TITLE, + fontweight="bold", + y=1.02, + ) + + # Multi-position + ax = axes[0] + ax.add_patch( + mpatches.Rectangle((0, 0), 8, 6, facecolor=GRAY4, edgecolor=LN, lw=1.5) + ) + # Grid of sliding windows + for i in range(4): + for j in range(3): + ax.add_patch( + mpatches.Rectangle( + (i * 1.8 + 0.2, j * 1.8 + 0.2), + 1.5, + 1.5, + facecolor="none", + edgecolor=LN, + lw=0.6, + linestyle="--", + ) + ) + # Highlight current window + ax.add_patch( + mpatches.Rectangle((2.0, 2.0), 1.5, 1.5, facecolor="none", edgecolor=LN, lw=2.5) + ) + ax.set_xlim(-0.5, 8.5) + ax.set_ylim(-0.5, 6.5) + ax.set_aspect("equal") + ax.axis("off") + ax.set_title("① Wiele pozycji\n(krok co 8 px)", fontsize=FS, fontweight="bold") + + # Multi-scale + ax = axes[1] + ax.add_patch( + mpatches.Rectangle((0, 0), 6, 5, facecolor=GRAY4, edgecolor=LN, lw=1.5) + ) + sizes = [(0.8, 0.8), (1.5, 1.5), (2.5, 2.5), (3.5, 3.5)] + for i, (w, h) in enumerate(sizes): + ax.add_patch( + mpatches.Rectangle( + (0.3 + i * 0.3, 0.3 + i * 0.3), + w, + h, + facecolor="none", + edgecolor=LN, + lw=1 + i * 0.3, + linestyle=[":", "--", "-.", "-"][i], + ) + ) + ax.text(3, 0, "4+ skal", ha="center", fontsize=FS_SMALL, fontweight="bold") + ax.set_xlim(-0.5, 6.5) + ax.set_ylim(-0.5, 5.5) + ax.set_aspect("equal") + ax.axis("off") + ax.set_title( + "② Wiele skal\n(obiekty mają różne rozmiary)", fontsize=FS, fontweight="bold" + ) + + # Count + ax = axes[2] + ax.axis("off") + ax.set_xlim(0, 6) + ax.set_ylim(0, 5) + + lines = [ + ("Obraz: 640 x 480 px", 4.5), + ("Okno: 64 x 64 px, krok 8 px", 3.8), + ("Pozycje: ~72 x 52 = 3 744", 3.1), + ("x 5 skal = 18 720 okien", 2.4), + ("x klasyfikacja = WOLNE!", 1.7), + ("→ ~3h na jeden obraz", 0.8), + ] + for text, yp in lines: + fw = "bold" if "~3h" in text or "WOLNE" in text else "normal" + col = GRAY2 if "WOLNE" in text or "~3h" in text else GRAY4 + ax.text( + 3.0, + yp, + text, + ha="center", + fontsize=FS, + fontweight=fw, + bbox={"boxstyle": "round,pad=0.2", "facecolor": col, "edgecolor": GRAY3}, + ) + + ax.set_title( + "③ Dlaczego wolne?\n(miliony klasyfikacji)", fontsize=FS, fontweight="bold" + ) + + fig.tight_layout() + save_fig(fig, "q24_sliding_window.png") diff --git a/python_pkg/praca_magisterska_video/generate_images/_q24_rcnn_yolo.py b/python_pkg/praca_magisterska_video/generate_images/_q24_rcnn_yolo.py new file mode 100644 index 0000000..26f134e --- /dev/null +++ b/python_pkg/praca_magisterska_video/generate_images/_q24_rcnn_yolo.py @@ -0,0 +1,344 @@ +"""R-CNN evolution and YOLO grid diagrams.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +from _q24_common import ( + FS, + FS_LABEL, + FS_SMALL, + FS_TITLE, + GRAY1, + GRAY2, + GRAY3, + GRAY4, + LN, + draw_arrow, + draw_box, + plt, + save_fig, +) +import matplotlib.patches as mpatches + +if TYPE_CHECKING: + from matplotlib.axes import Axes + + +def _draw_yolo_cell_prediction(ax: Axes) -> None: + """Draw YOLO cell prediction vector panel.""" + ax.axis("off") + ax.set_xlim(0, 6) + ax.set_ylim(-1, 5) + + labels = [ + "x", + "y", + "w", + "h", + "conf", + "x", + "y", + "w", + "h", + "conf", + "P(c₁)", + "...", + "P(c₂₀)", + ] + colors_vec = [GRAY4] * 5 + [GRAY2] * 5 + [GRAY1] * 3 + + bw = 0.42 + for i, (label, col) in enumerate( + zip(labels, colors_vec, strict=False), + ): + x_pos = 0.3 + i * bw + ax.add_patch( + mpatches.Rectangle( + (x_pos, 2.5), + bw - 0.02, + 0.6, + facecolor=col, + edgecolor=LN, + lw=0.8, + ) + ) + ax.text( + x_pos + bw / 2, + 2.8, + label, + ha="center", + va="center", + fontsize=5, + fontweight="bold", + ) + + ax.annotate( + "", + xy=(0.3, 2.4), + xytext=(2.4, 2.4), + arrowprops={"arrowstyle": "-", "lw": 1}, + ) + ax.text(1.35, 2.15, "bbox 1 (5 wartości)", ha="center", fontsize=FS_SMALL) + + ax.annotate( + "", + xy=(2.4, 2.4), + xytext=(4.5, 2.4), + arrowprops={"arrowstyle": "-", "lw": 1}, + ) + ax.text(3.45, 2.15, "bbox 2 (5 wartości)", ha="center", fontsize=FS_SMALL) + + ax.annotate( + "", + xy=(4.5, 2.4), + xytext=(5.8, 2.4), + arrowprops={"arrowstyle": "-", "lw": 1}, + ) + ax.text(5.15, 2.15, "20 klas", ha="center", fontsize=FS_SMALL) + + ax.text( + 3.0, + 3.5, + "Każda komórka → 30 wartości\n= 2x(x,y,w,h,conf) + 20 klas", + ha="center", + fontsize=FS, + fontweight="bold", + bbox={"boxstyle": "round,pad=0.3", "facecolor": GRAY4, "edgecolor": GRAY3}, + ) + + ax.set_title( + "② Predykcja jednej komórki\n(S=7, B=2, C=20)", + fontsize=FS, + fontweight="bold", + ) + + +# ============================================================ +# 6. R-CNN Evolution +# ============================================================ +def draw_rcnn_evolution() -> None: + """Draw rcnn evolution.""" + fig, ax = plt.subplots(figsize=(11, 7)) + ax.set_xlim(-0.5, 11) + ax.set_ylim(-0.5, 7.5) + ax.set_aspect("equal") + ax.axis("off") + ax.set_title( + "Ewolucja R-CNN: od 50s do 0.2s na obraz", + fontsize=FS_TITLE, + fontweight="bold", + pad=12, + ) + + y_positions = [5.5, 3.0, 0.5] + labels = [ + "R-CNN (2014) — 50 s/obraz", + "Fast R-CNN (2015) — 2 s/obraz", + "Faster R-CNN (2015) — 0.2 s/obraz", + ] + + # R-CNN + y = y_positions[0] + ax.text(0, y + 1.3, labels[0], fontsize=FS_LABEL, fontweight="bold") + draw_box(ax, 0, y, 2, 0.9, "Selective\nSearch", fill=GRAY2, fontsize=FS) + draw_arrow(ax, 2.1, y + 0.45, 2.5, y + 0.45) + ax.text(2.3, y + 0.8, "~2000", ha="center", fontsize=FS_SMALL, style="italic") + draw_box(ax, 2.6, y, 1.5, 0.9, "Resize\n224x224", fill=GRAY4, fontsize=FS) + draw_arrow(ax, 4.2, y + 0.45, 4.6, y + 0.45) + draw_box( + ax, 4.7, y, 1.5, 0.9, "CNN\nx2000!", fill=GRAY3, fontsize=FS, fontweight="bold" + ) + draw_arrow(ax, 6.3, y + 0.45, 6.7, y + 0.45) + draw_box(ax, 6.8, y, 1.3, 0.9, "SVM\nklasyf.", fill=GRAY4, fontsize=FS) + draw_arrow(ax, 8.2, y + 0.45, 8.6, y + 0.45) + draw_box(ax, 8.7, y, 1.0, 0.9, "NMS", fill=GRAY1, fontsize=FS) + # Problem annotation + ax.text( + 5.5, + y - 0.4, + "⚠ CNN uruchamiane 2000x → 50 sek!", + ha="center", + fontsize=FS_SMALL, + fontweight="bold", + bbox={"boxstyle": "round,pad=0.2", "facecolor": GRAY4, "edgecolor": GRAY3}, + ) + + # Fast R-CNN + y = y_positions[1] + ax.text(0, y + 1.3, labels[1], fontsize=FS_LABEL, fontweight="bold") + draw_box(ax, 0, y, 2, 0.9, "Selective\nSearch", fill=GRAY2, fontsize=FS) + draw_arrow(ax, 2.1, y + 0.45, 2.5, y + 0.45) + draw_box( + ax, + 2.6, + y, + 1.5, + 0.9, + "CNN\nx1 (RAZ!)", + fill=GRAY3, + fontsize=FS, + fontweight="bold", + ) + draw_arrow(ax, 4.2, y + 0.45, 4.6, y + 0.45) + draw_box( + ax, 4.7, y, 1.5, 0.9, "ROI\nPooling", fill=GRAY1, fontsize=FS, fontweight="bold" + ) + draw_arrow(ax, 6.3, y + 0.45, 6.7, y + 0.45) + draw_box(ax, 6.8, y, 1.3, 0.9, "FC\nklasa+bbox", fill=GRAY4, fontsize=FS) + draw_arrow(ax, 8.2, y + 0.45, 8.6, y + 0.45) + draw_box(ax, 8.7, y, 1.0, 0.9, "NMS", fill=GRAY1, fontsize=FS) + ax.text( + 3.8, + y - 0.4, + "✓ CNN RAZ na cały obraz → 25x szybciej", + ha="center", + fontsize=FS_SMALL, + fontweight="bold", + bbox={"boxstyle": "round,pad=0.2", "facecolor": GRAY4, "edgecolor": GRAY3}, + ) + + # Faster R-CNN + y = y_positions[2] + ax.text(0, y + 1.3, labels[2], fontsize=FS_LABEL, fontweight="bold") + draw_box( + ax, + 0.5, + y, + 1.5, + 0.9, + "CNN\nBackbone", + fill=GRAY3, + fontsize=FS, + fontweight="bold", + ) + draw_arrow(ax, 2.1, y + 0.45, 2.5, y + 0.45) + draw_box(ax, 2.6, y, 1.5, 0.9, "Feature\nMap", fill=GRAY1, fontsize=FS) + draw_arrow(ax, 4.2, y + 0.45, 4.6, y + 0.45) + draw_box( + ax, + 4.7, + y, + 1.3, + 0.9, + "RPN\n(w sieci!)", + fill=GRAY2, + fontsize=FS, + fontweight="bold", + ) + draw_arrow(ax, 6.1, y + 0.45, 6.5, y + 0.45) + draw_box(ax, 6.6, y, 1.3, 0.9, "ROI\nPooling", fill=GRAY1, fontsize=FS) + draw_arrow(ax, 8.0, y + 0.45, 8.4, y + 0.45) + draw_box(ax, 8.5, y, 1.3, 0.9, "FC\nklasa+bbox", fill=GRAY4, fontsize=FS) + ax.text( + 5.0, + y - 0.4, + "✓ RPN zastępuje Selective Search → end-to-end", + ha="center", + fontsize=FS_SMALL, + fontweight="bold", + bbox={"boxstyle": "round,pad=0.2", "facecolor": GRAY4, "edgecolor": GRAY3}, + ) + + save_fig(fig, "q24_rcnn_evolution.png") + + +# ============================================================ +# 7. YOLO Grid +# ============================================================ +def draw_yolo_grid() -> None: + """Draw yolo grid.""" + fig, axes = plt.subplots(1, 3, figsize=(12, 4)) + fig.suptitle( + "YOLO — detekcja jednoetapowa (siatka SxS)", + fontsize=FS_TITLE, + fontweight="bold", + y=1.02, + ) + + # Grid on image + ax = axes[0] + grid_size = 7 + ax.set_xlim(0, grid_size) + ax.set_ylim(0, grid_size) + for i in range(grid_size + 1): + ax.axhline(y=i, color=LN, lw=0.5, alpha=0.5) + ax.axvline(x=i, color=LN, lw=0.5, alpha=0.5) + ax.add_patch( + mpatches.Rectangle( + (0, 0), + grid_size, + grid_size, + facecolor=GRAY4, + edgecolor=LN, + lw=1.5, + ) + ) + # Highlight one cell + ax.add_patch(mpatches.Rectangle((3, 3), 1, 1, facecolor=GRAY2, edgecolor=LN, lw=2)) + # Object center dot + ax.plot(3.5, 3.5, "ko", markersize=8) + # Bounding box from that cell + ax.add_patch( + mpatches.Rectangle( + (2.0, 2.2), 3.0, 2.6, facecolor="none", edgecolor=LN, lw=2, linestyle="--" + ) + ) + ax.text( + 3.5, + 1.8, + "bbox z komórki (3,3)", + ha="center", + fontsize=FS_SMALL, + fontweight="bold", + ) + ax.set_aspect("equal") + ax.invert_yaxis() + ax.set_title("① Siatka 7x7\nna obrazie", fontsize=FS, fontweight="bold") + ax.set_xticks([]) + ax.set_yticks([]) + + _draw_yolo_cell_prediction(axes[1]) + + # Speed comparison + ax = axes[2] + ax.axis("off") + ax.set_xlim(0, 5) + ax.set_ylim(0, 5) + + methods = ["R-CNN", "Fast R-CNN", "Faster R-CNN", "YOLO", "YOLOv8"] + fps_vals = [0.02, 0.5, 5, 45, 100] + bar_colors = [GRAY3, GRAY3, GRAY3, GRAY2, GRAY1] + + for i, (m, f, c) in enumerate(zip(methods, fps_vals, bar_colors, strict=False)): + bar_w = f / 100 * 4.0 + y_pos = 4.0 - i * 0.8 + ax.add_patch( + mpatches.Rectangle( + (0.5, y_pos), max(bar_w, 0.1), 0.5, facecolor=c, edgecolor=LN, lw=0.8 + ) + ) + ax.text( + 0.4, + y_pos + 0.25, + m, + ha="right", + va="center", + fontsize=FS, + fontweight="bold", + ) + ax.text( + max(0.7, 0.5 + bar_w + 0.1), + y_pos + 0.25, + f"{f} fps", + ha="left", + va="center", + fontsize=FS, + ) + + ax.set_title( + "③ Porównanie szybkości\n(fps = klatki/sek)", fontsize=FS, fontweight="bold" + ) + + fig.tight_layout() + save_fig(fig, "q24_yolo_grid.png") diff --git a/python_pkg/praca_magisterska_video/generate_images/_q31_common.py b/python_pkg/praca_magisterska_video/generate_images/_q31_common.py new file mode 100644 index 0000000..7654d1d --- /dev/null +++ b/python_pkg/praca_magisterska_video/generate_images/_q31_common.py @@ -0,0 +1,102 @@ +"""Common constants and utilities for Q31 diagrams.""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import TYPE_CHECKING + +import matplotlib.patches as mpatches +from matplotlib.patches import FancyBboxPatch + +if TYPE_CHECKING: + from matplotlib.axes import Axes + +_logger = logging.getLogger(__name__) + +DPI = 300 +BG = "white" +LN = "black" +FS = 8 +FS_TITLE = 11 +OUTPUT_DIR = str(Path(__file__).resolve().parent / "img") +Path(OUTPUT_DIR).mkdir(parents=True, exist_ok=True) + +GRAY1 = "#E8E8E8" +GRAY2 = "#D0D0D0" +GRAY3 = "#B8B8B8" +GRAY4 = "#F5F5F5" +GRAY5 = "#C0C0C0" + +# Number of regret table header columns before the max-regret column +_REGRET_HEADER_COLS = 4 +# Number of data state columns +_DATA_STATE_COLS = 3 +# Expected-value for the winning alternative +_WINNING_EV = 95 + + +def draw_box( + ax: Axes, + x: float, + y: float, + w: float, + h: float, + text: str, + *, + fill: str = "white", + lw: float = 1.2, + fontsize: float = FS, + fontweight: str = "normal", + ha: str = "center", + va: str = "center", + rounded: bool = True, +) -> None: + """Draw a labeled box on the axes.""" + if rounded: + rect = FancyBboxPatch( + (x, y), + w, + h, + boxstyle="round,pad=0.05", + lw=lw, + edgecolor=LN, + facecolor=fill, + ) + else: + rect = mpatches.Rectangle((x, y), w, h, lw=lw, edgecolor=LN, facecolor=fill) + ax.add_patch(rect) + ax.text( + x + w / 2, + y + h / 2, + text, + ha=ha, + va=va, + fontsize=fontsize, + fontweight=fontweight, + wrap=True, + ) + + +def draw_arrow( + ax: Axes, + x1: float, + y1: float, + x2: float, + y2: float, + *, + lw: float = 1.2, + style: str = "->", + color: str = LN, +) -> None: + """Draw an arrow between two points.""" + ax.annotate( + "", + xy=(x2, y2), + xytext=(x1, y1), + arrowprops={ + "arrowstyle": style, + "color": color, + "lw": lw, + }, + ) diff --git a/python_pkg/praca_magisterska_video/generate_images/_q31_criteria_comparison.py b/python_pkg/praca_magisterska_video/generate_images/_q31_criteria_comparison.py new file mode 100644 index 0000000..9855e30 --- /dev/null +++ b/python_pkg/praca_magisterska_video/generate_images/_q31_criteria_comparison.py @@ -0,0 +1,256 @@ +"""Q31 Diagram 1: Payoff matrix + all criteria bar chart.""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +import matplotlib.patches as mpatches +import matplotlib.pyplot as plt +import numpy as np + +from python_pkg.praca_magisterska_video.generate_images._q31_common import ( + _DATA_STATE_COLS, + BG, + DPI, + FS, + FS_TITLE, + GRAY1, + GRAY2, + GRAY3, + GRAY4, + GRAY5, + LN, + OUTPUT_DIR, + _logger, +) + +if TYPE_CHECKING: + from matplotlib.axes import Axes + + +def _draw_payoff_table(ax: Axes) -> None: + """Draw the payoff matrix table on the left panel.""" + ax.axis("off") + ax.set_xlim(0, 6) + ax.set_ylim(0, 6) + ax.set_title( + "Macierz wypłat (tys. zł)", + fontsize=FS_TITLE, + fontweight="bold", + pad=8, + ) + + headers_col = ["", "S₁\n(dobra)", "S₂\n(średnia)", "S₃\n(zła)"] + rows = [ + ["A₁ (fabryka)", "200", "50", "-100"], + ["A₂ (sklep)", "80", "70", "40"], + ["A₃ (obligacje)", "30", "30", "30"], + ] + + col_w = [1.8, 1.2, 1.2, 1.2] + row_h = 0.7 + start_y = 4.5 + start_x = 0.2 + + # Draw header row + x = start_x + for j, h in enumerate(headers_col): + fill = GRAY2 if j > 0 else GRAY3 + rect = mpatches.Rectangle( + (x, start_y), + col_w[j], + row_h, + lw=1, + edgecolor=LN, + facecolor=fill, + ) + ax.add_patch(rect) + ax.text( + x + col_w[j] / 2, + start_y + row_h / 2, + h, + ha="center", + va="center", + fontsize=FS, + fontweight="bold", + ) + x += col_w[j] + + # Draw data rows + for i, row in enumerate(rows): + x = start_x + y = start_y - (i + 1) * row_h + for j, val in enumerate(row): + fill = GRAY4 if j == 0 else ("white" if i % 2 == 0 else GRAY1) + if val.startswith("-"): + fill = "#D8D8D8" + rect = mpatches.Rectangle( + (x, y), + col_w[j], + row_h, + lw=1, + edgecolor=LN, + facecolor=fill, + ) + ax.add_patch(rect) + fw = "bold" if j == 0 else "normal" + ax.text( + x + col_w[j] / 2, + y + row_h / 2, + val, + ha="center", + va="center", + fontsize=FS, + fontweight=fw, + ) + x += col_w[j] + + # Probability row for EV + x = start_x + y = start_y - 4 * row_h + probs = ["p (dla E[X]):", "0.5", "0.3", "0.2"] + for j, val in enumerate(probs): + fill = GRAY5 if j > 0 else GRAY3 + rect = mpatches.Rectangle( + (x, y), + col_w[j], + row_h * 0.7, + lw=1, + edgecolor=LN, + facecolor=fill, + ) + ax.add_patch(rect) + ax.text( + x + col_w[j] / 2, + y + row_h * 0.35, + val, + ha="center", + va="center", + fontsize=7, + fontweight="bold", + style="italic", + ) + x += col_w[j] + + +def _draw_criteria_bars(ax2: Axes) -> None: + """Draw the criteria comparison bar chart on the right panel.""" + criteria = [ + "E[X]", + "Laplace", + "Maximax", + "Maximin", + "Hurwicz\n\u03b1=0.6", + "Savage", + ] + + ev = [95, 69, 30] + laplace = [50, 63.3, 30] + maximax = [200, 80, 30] + maximin = [-100, 40, 30] + hurwicz = [80, 64, 30] + savage_maxregret = [140, 120, 170] + + winners = [0, 1, 0, 1, 0, 1] + + x_pos = np.arange(len(criteria)) + width = 0.22 + hatches = ["///", "...", "xxx"] + labels = ["A₁ (fabryka)", "A₂ (sklep)", "A₃ (obligacje)"] + + all_vals = [ + [ + ev[0], + laplace[0], + maximax[0], + maximin[0], + hurwicz[0], + savage_maxregret[0], + ], + [ + ev[1], + laplace[1], + maximax[1], + maximin[1], + hurwicz[1], + savage_maxregret[1], + ], + [ + ev[2], + laplace[2], + maximax[2], + maximin[2], + hurwicz[2], + savage_maxregret[2], + ], + ] + + for i in range(_DATA_STATE_COLS): + ax2.bar( + x_pos + (i - 1) * width, + all_vals[i], + width, + label=labels[i], + color="white", + edgecolor=LN, + hatch=hatches[i], + lw=0.8, + ) + + for c_idx in range(len(criteria)): + w = winners[c_idx] + val = all_vals[w][c_idx] + ax2.text( + x_pos[c_idx] + (w - 1) * width, + val + 5, + "★", + ha="center", + va="bottom", + fontsize=10, + fontweight="bold", + ) + + ax2.set_xticks(x_pos) + ax2.set_xticklabels(criteria, fontsize=7) + ax2.set_ylabel("Wartość kryterium", fontsize=8) + ax2.set_title( + "Porównanie kryteriów", + fontsize=FS_TITLE, + fontweight="bold", + pad=8, + ) + ax2.legend(fontsize=7, loc="upper right") + ax2.axhline(y=0, color=LN, lw=0.5, ls="-") + ax2.spines["top"].set_visible(False) + ax2.spines["right"].set_visible(False) + ax2.tick_params(labelsize=7) + + ax2.text( + 5, + -30, + "(Savage: niżej\n= lepiej)", + fontsize=6, + ha="center", + va="top", + style="italic", + ) + + +def draw_criteria_comparison() -> None: + """Draw payoff matrix and criteria comparison chart.""" + fig, axes = plt.subplots( + 1, + 2, + figsize=(8.27, 4.5), + gridspec_kw={"width_ratios": [1.2, 1]}, + ) + + _draw_payoff_table(axes[0]) + _draw_criteria_bars(axes[1]) + + plt.tight_layout() + outpath = str(Path(OUTPUT_DIR) / "q31_criteria_comparison.png") + fig.savefig(outpath, dpi=DPI, bbox_inches="tight", facecolor=BG) + plt.close(fig) + _logger.info(" Saved: %s", outpath) diff --git a/python_pkg/praca_magisterska_video/generate_images/_q31_ev_spectrum.py b/python_pkg/praca_magisterska_video/generate_images/_q31_ev_spectrum.py new file mode 100644 index 0000000..71a1a8f --- /dev/null +++ b/python_pkg/praca_magisterska_video/generate_images/_q31_ev_spectrum.py @@ -0,0 +1,289 @@ +"""Q31 Diagrams 5 & 6: Expected value + decision conditions spectrum.""" + +from __future__ import annotations + +from pathlib import Path + +import matplotlib.patches as mpatches +from matplotlib.patches import FancyBboxPatch +import matplotlib.pyplot as plt + +from python_pkg.praca_magisterska_video.generate_images._q31_common import ( + _WINNING_EV, + BG, + DPI, + FS_TITLE, + GRAY1, + GRAY2, + GRAY3, + LN, + OUTPUT_DIR, + _logger, +) + + +def draw_expected_value() -> None: + """Draw expected value criterion with probability-weighted bars.""" + fig, axes = plt.subplots(1, 3, figsize=(8.27, 3.5), sharey=True) + fig.suptitle( + "Kryterium wartości oczekiwanej E[X]" " \u2014 rozkład wyników per alternatywa", + fontsize=FS_TITLE, + fontweight="bold", + y=1.02, + ) + + probs = [0.5, 0.3, 0.2] + alts = [ + ("A₁ (fabryka)", [200, 50, -100], 95), + ("A₂ (sklep)", [80, 70, 40], 69), + ("A₃ (obligacje)", [30, 30, 30], 30), + ] + + hatches = ["///", "...", "xxx"] + + for _idx, (ax, (name, vals, ev)) in enumerate(zip(axes, alts, strict=False)): + x_positions = [0, 0.6, 1.0] + widths = [p * 0.9 for p in probs] + + for i, (v, p, h) in enumerate(zip(vals, probs, hatches, strict=False)): + color = "white" if v >= 0 else GRAY2 + ax.bar( + x_positions[i], + v, + width=widths[i], + color=color, + edgecolor=LN, + hatch=h, + lw=0.8, + align="edge", + ) + offset = 8 if v >= 0 else -12 + ax.text( + x_positions[i] + widths[i] / 2, + v + offset, + f"{v}", + ha="center", + va="center", + fontsize=8, + fontweight="bold", + ) + contrib = v * p + ax.text( + x_positions[i] + widths[i] / 2, + v / 2, + f"{v}x{p}\n={contrib:.0f}", + ha="center", + va="center", + fontsize=6, + style="italic", + ) + + # Expected value line + ax.axhline(y=ev, color=LN, lw=2, ls="--") + ax.text( + 1.35, + ev, + f"E[X]={ev}", + fontsize=8, + fontweight="bold", + va="center", + ha="left", + bbox={ + "boxstyle": "round,pad=0.15", + "facecolor": GRAY1, + "edgecolor": LN, + }, + ) + + ax.set_title(name, fontsize=9, fontweight="bold") + ax.set_xticks([0.225, 0.735, 1.09]) + ax.set_xticklabels(["S₁", "S₂", "S₃"], fontsize=7) + ax.axhline(y=0, color=LN, lw=0.5) + ax.spines["top"].set_visible(False) + ax.spines["right"].set_visible(False) + ax.tick_params(labelsize=7) + + # Star on winner + if ev == _WINNING_EV: + ax.text( + 0.7, + ev + 20, + "★ MAX", + fontsize=9, + fontweight="bold", + ha="center", + va="bottom", + ) + + axes[0].set_ylabel("Wypłata (tys. zł)", fontsize=8) + + plt.tight_layout() + outpath = str(Path(OUTPUT_DIR) / "q31_expected_value.png") + fig.savefig(outpath, dpi=DPI, bbox_inches="tight", facecolor=BG) + plt.close(fig) + _logger.info(" Saved: %s", outpath) + + +def draw_conditions_spectrum() -> None: + """Draw decision conditions spectrum diagram.""" + fig, ax = plt.subplots(1, 1, figsize=(8.27, 3.5)) + ax.set_xlim(0, 10) + ax.set_ylim(0, 5) + ax.set_aspect("equal") + ax.axis("off") + ax.set_title( + "Warunki decyzyjne" " \u2014 spektrum wiedzy decydenta", + fontsize=FS_TITLE + 1, + fontweight="bold", + pad=10, + ) + + # Three zones + zones = [ + ( + 0.3, + 1.5, + 2.8, + 2.5, + "PEWNOŚĆ", + "white", + [ + "Znamy dokładny wynik", + "Przykład: lokata 5%", + "Metoda: po prostu wybierz", + "najlepszy wynik", + ], + ), + ( + 3.5, + 1.5, + 2.8, + 2.5, + "RYZYKO", + GRAY1, + [ + "Znamy wyniki I prawdop.", + "Przykład: gra w kości", + "Metoda: wartość", + "oczekiwana E[X]", + ], + ), + ( + 6.7, + 1.5, + 2.8, + 2.5, + "NIEPEWNOŚĆ", + GRAY3, + [ + "Znamy wyniki, ale", + "NIE znamy prawdop.", + "Metody: Laplace, maximax,", + "maximin, Hurwicz, Savage", + ], + ), + ] + + for x, y, w, h, title, fill, lines in zones: + rect = FancyBboxPatch( + (x, y), + w, + h, + boxstyle="round,pad=0.1", + lw=2, + edgecolor=LN, + facecolor=fill, + ) + ax.add_patch(rect) + ax.text( + x + w / 2, + y + h - 0.3, + title, + ha="center", + va="center", + fontsize=11, + fontweight="bold", + ) + for i, line in enumerate(lines): + ax.text( + x + w / 2, + y + h - 0.7 - i * 0.4, + line, + ha="center", + va="center", + fontsize=7, + ) + + # Arrows between zones + ax.annotate( + "", + xy=(3.4, 2.75), + xytext=(3.15, 2.75), + arrowprops={"arrowstyle": "->", "color": LN, "lw": 2}, + ) + ax.annotate( + "", + xy=(6.6, 2.75), + xytext=(6.35, 2.75), + arrowprops={"arrowstyle": "->", "color": LN, "lw": 2}, + ) + + # Bottom: knowledge gradient bar + gradient_y = 0.5 + gradient_h = 0.5 + n_steps = 50 + for i in range(n_steps): + x = 0.3 + i * (9.2 / n_steps) + w = 9.2 / n_steps + 0.01 + gray_val = 1 - (i / n_steps) * 0.7 + rect = mpatches.Rectangle( + (x, gradient_y), + w, + gradient_h, + lw=0, + facecolor=str(gray_val), + ) + ax.add_patch(rect) + + rect = mpatches.Rectangle( + (0.3, gradient_y), + 9.2, + gradient_h, + lw=1.5, + edgecolor=LN, + facecolor="none", + ) + ax.add_patch(rect) + + ax.text( + 0.3, + gradient_y - 0.15, + "Dużo wiedzy", + fontsize=7, + ha="left", + va="top", + ) + ax.text( + 9.5, + gradient_y - 0.15, + "Mało wiedzy", + fontsize=7, + ha="right", + va="top", + ) + ax.text( + 4.95, + gradient_y + gradient_h / 2, + "POZIOM WIEDZY DECYDENTA", + fontsize=8, + fontweight="bold", + ha="center", + va="center", + color="white", + ) + + plt.tight_layout() + outpath = str(Path(OUTPUT_DIR) / "q31_conditions_spectrum.png") + fig.savefig(outpath, dpi=DPI, bbox_inches="tight", facecolor=BG) + plt.close(fig) + _logger.info(" Saved: %s", outpath) diff --git a/python_pkg/praca_magisterska_video/generate_images/_q31_hurwicz_mnemonic.py b/python_pkg/praca_magisterska_video/generate_images/_q31_hurwicz_mnemonic.py new file mode 100644 index 0000000..eeb2751 --- /dev/null +++ b/python_pkg/praca_magisterska_video/generate_images/_q31_hurwicz_mnemonic.py @@ -0,0 +1,344 @@ +"""Q31 Diagrams 3 & 4: Hurwicz interpolation + criteria mnemonic map.""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +from matplotlib.patches import FancyBboxPatch +import matplotlib.pyplot as plt +import numpy as np + +from python_pkg.praca_magisterska_video.generate_images._q31_common import ( + BG, + DPI, + FS_TITLE, + GRAY1, + GRAY2, + GRAY3, + LN, + OUTPUT_DIR, + _logger, + draw_box, +) + +if TYPE_CHECKING: + from matplotlib.axes import Axes + + +def draw_hurwicz_interpolation() -> None: + """Draw Hurwicz alpha interpolation diagram.""" + fig, ax = plt.subplots(1, 1, figsize=(8.27, 4)) + ax.set_title( + "Kryterium Hurwicza" " \u2014 wpływ \u03b1 na wybór alternatywy", + fontsize=FS_TITLE + 1, + fontweight="bold", + pad=10, + ) + + alphas = np.linspace(0, 1, 200) + + v1 = alphas * 200 + (1 - alphas) * (-100) + v2 = alphas * 80 + (1 - alphas) * 40 + v3 = alphas * 30 + (1 - alphas) * 30 + + ax.plot( + alphas, + v1, + "k-", + lw=2, + label="A₁ (fabryka): V = 300\u03b1 - 100", + ) + ax.plot( + alphas, + v2, + "k--", + lw=2, + label="A₂ (sklep): V = 40\u03b1 + 40", + ) + ax.plot( + alphas, + v3, + "k:", + lw=2, + label="A₃ (obligacje): V = 30", + ) + + # Crossover A2=A1 + alpha_cross_12 = 140 / 260 + v_cross_12 = 40 * alpha_cross_12 + 40 + + ax.plot(alpha_cross_12, v_cross_12, "ko", markersize=8, zorder=5) + ax.annotate( + f"\u03b1 ≈ {alpha_cross_12:.2f}\nA₁ = A₂", + xy=(alpha_cross_12, v_cross_12), + xytext=(alpha_cross_12 + 0.12, v_cross_12 - 30), + fontsize=8, + fontweight="bold", + arrowprops={ + "arrowstyle": "->", + "color": LN, + "lw": 1, + }, + ) + + # Shade winning regions + ax.axvspan(0, alpha_cross_12, alpha=0.08, color="black") + ax.axvspan(alpha_cross_12, 1, alpha=0.15, color="black") + + ax.text( + alpha_cross_12 / 2, + -60, + "A₂ wygrywa\n(pesymistycznie)", + fontsize=8, + ha="center", + va="center", + bbox={ + "boxstyle": "round", + "facecolor": "white", + "edgecolor": LN, + }, + ) + ax.text( + (alpha_cross_12 + 1) / 2, + 160, + "A₁ wygrywa\n(optymistycznie)", + fontsize=8, + ha="center", + va="center", + bbox={ + "boxstyle": "round", + "facecolor": "white", + "edgecolor": LN, + }, + ) + + # Special alpha values + ax.axvline(x=0, color=LN, lw=0.5, ls=":") + ax.axvline(x=1, color=LN, lw=0.5, ls=":") + ax.text( + 0, + -115, + "\u03b1=0\nmaximin", + fontsize=7, + ha="center", + va="top", + fontweight="bold", + ) + ax.text( + 1, + -115, + "\u03b1=1\nmaximax", + fontsize=7, + ha="center", + va="top", + fontweight="bold", + ) + + ax.set_xlabel("Współczynnik optymizmu \u03b1", fontsize=9) + ax.set_ylabel("V(Aᵢ) = \u03b1·max + (1-\u03b1)·min", fontsize=9) + ax.legend(fontsize=8, loc="upper left") + ax.spines["top"].set_visible(False) + ax.spines["right"].set_visible(False) + ax.set_xlim(-0.05, 1.05) + ax.axhline(y=0, color=LN, lw=0.3, ls="-") + ax.tick_params(labelsize=8) + + plt.tight_layout() + outpath = str(Path(OUTPUT_DIR) / "q31_hurwicz_alpha.png") + fig.savefig(outpath, dpi=DPI, bbox_inches="tight", facecolor=BG) + plt.close(fig) + _logger.info(" Saved: %s", outpath) + + +def _draw_mnemonic_criteria_boxes(ax: Axes) -> None: + """Draw the 6 criteria boxes around the center.""" + criteria = [ + ( + 0, + 6.5, + 3, + 1.2, + "WARTOŚĆ OCZEKIWANA", + "\u201eMam prawdopodobieństwa\u201d", + "E[Aᵢ] = Σ pⱼ·aᵢⱼ", + ), + ( + 3.5, + 6.5, + 3, + 1.2, + "LAPLACE", + "\u201eWszystko po równo\u201d", + "V = Σaᵢⱼ / n", + ), + ( + 7, + 6.5, + 3, + 1.2, + "MAXIMAX", + "\u201eOptymista: max z max\u201d", + "max maxⱼ aᵢⱼ", + ), + ( + 0, + 0.5, + 3, + 1.2, + "MAXIMIN (Wald)", + "\u201ePesymista: max z min\u201d", + "max minⱼ aᵢⱼ", + ), + ( + 3.5, + 0.5, + 3, + 1.2, + "HURWICZ", + "\u201e\u03b1 pomiędzy\u201d", + "\u03b1·max + (1-\u03b1)·min", + ), + ( + 7, + 0.5, + 3, + 1.2, + "SAVAGE", + "\u201eMin max żalu\u201d", + "min maxⱼ rᵢⱼ", + ), + ] + + fills = [GRAY3, GRAY1, "white", "white", GRAY1, GRAY3] + + for i, (x, y, w, h, title, mnem, formula) in enumerate(criteria): + rect = FancyBboxPatch( + (x, y), + w, + h, + boxstyle="round,pad=0.08", + lw=1.5, + edgecolor=LN, + facecolor=fills[i], + ) + ax.add_patch(rect) + ax.text( + x + w / 2, + y + h * 0.78, + title, + ha="center", + va="center", + fontsize=8, + fontweight="bold", + ) + ax.text( + x + w / 2, + y + h * 0.45, + mnem, + ha="center", + va="center", + fontsize=7, + style="italic", + ) + ax.text( + x + w / 2, + y + h * 0.15, + formula, + ha="center", + va="center", + fontsize=7, + fontweight="bold", + family="monospace", + ) + + # Arrows from center to each box + cx, cy = 5, 4 + bx = x + w / 2 + by_center = y + h / 2 + if by_center > cy: + ax.annotate( + "", + xy=(bx, y), + xytext=(cx, 4.5), + arrowprops={ + "arrowstyle": "->", + "color": LN, + "lw": 1, + "connectionstyle": "arc3,rad=0", + }, + ) + else: + ax.annotate( + "", + xy=(bx, y + h), + xytext=(cx, 3.5), + arrowprops={ + "arrowstyle": "->", + "color": LN, + "lw": 1, + "connectionstyle": "arc3,rad=0", + }, + ) + + # Labels on arrows + arrow_labels = [ + (1.2, 5.6, "znane p"), + (5, 5.6, "p = 1/n"), + (8.7, 5.6, "max ↑"), + (1.2, 2.5, "min ↑"), + (5, 2.5, "podaj \u03b1"), + (8.7, 2.5, "macierz\nżalu"), + ] + for lx, ly, ltext in arrow_labels: + ax.text( + lx, + ly, + ltext, + fontsize=7, + ha="center", + va="center", + bbox={ + "boxstyle": "round,pad=0.15", + "facecolor": "white", + "edgecolor": GRAY3, + "lw": 0.5, + }, + ) + + +def draw_criteria_mnemonic() -> None: + """Draw decision criteria mnemonic map diagram.""" + fig, ax = plt.subplots(1, 1, figsize=(8.27, 6)) + ax.set_xlim(0, 10) + ax.set_ylim(0, 8) + ax.set_aspect("equal") + ax.axis("off") + ax.set_title( + "Mapa mnemoniczna \u2014 6 kryteriów decyzyjnych", + fontsize=FS_TITLE + 2, + fontweight="bold", + pad=10, + ) + + # Central node + draw_box( + ax, + 3.5, + 3.5, + 3, + 1, + "MACIERZ\nWYPŁAT", + fill=GRAY2, + lw=2, + fontsize=11, + fontweight="bold", + ) + + _draw_mnemonic_criteria_boxes(ax) + + plt.tight_layout() + outpath = str(Path(OUTPUT_DIR) / "q31_criteria_mnemonic.png") + fig.savefig(outpath, dpi=DPI, bbox_inches="tight", facecolor=BG) + plt.close(fig) + _logger.info(" Saved: %s", outpath) diff --git a/python_pkg/praca_magisterska_video/generate_images/_q31_regret_matrix.py b/python_pkg/praca_magisterska_video/generate_images/_q31_regret_matrix.py new file mode 100644 index 0000000..427b559 --- /dev/null +++ b/python_pkg/praca_magisterska_video/generate_images/_q31_regret_matrix.py @@ -0,0 +1,322 @@ +"""Q31 Diagram 2: Regret matrix construction step-by-step.""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +import matplotlib.patches as mpatches +import matplotlib.pyplot as plt + +from python_pkg.praca_magisterska_video.generate_images._q31_common import ( + _DATA_STATE_COLS, + _REGRET_HEADER_COLS, + BG, + DPI, + FS, + FS_TITLE, + GRAY1, + GRAY2, + GRAY3, + GRAY4, + LN, + OUTPUT_DIR, + _logger, +) + +if TYPE_CHECKING: + from matplotlib.axes import Axes + + +def _draw_original_payoff( + ax: Axes, + start_y: float, + row_h: float, +) -> None: + """Draw the original payoff matrix (left side of regret fig).""" + ax.text( + 2.2, + 6.3, + "Krok 1: Macierz wypłat", + fontsize=9, + fontweight="bold", + ha="center", + va="center", + ) + + col_w = 1.0 + headers = ["", "S₁", "S₂", "S₃"] + data = [ + ["A₁", "200", "50", "-100"], + ["A₂", "80", "70", "40"], + ["A₃", "30", "30", "30"], + ] + start_x = 0.3 + + for j, h in enumerate(headers): + w = 0.7 if j == 0 else col_w + x = start_x + (0 if j == 0 else 0.7 + (j - 1) * col_w) + rect = mpatches.Rectangle( + (x, start_y), + w, + row_h, + lw=1, + edgecolor=LN, + facecolor=GRAY2, + ) + ax.add_patch(rect) + ax.text( + x + w / 2, + start_y + row_h / 2, + h, + ha="center", + va="center", + fontsize=FS, + fontweight="bold", + ) + + for i, row in enumerate(data): + y = start_y - (i + 1) * row_h + for j, val in enumerate(row): + w = 0.7 if j == 0 else col_w + x = start_x + (0 if j == 0 else 0.7 + (j - 1) * col_w) + fill = GRAY4 if j == 0 else "white" + rect = mpatches.Rectangle( + (x, y), + w, + row_h, + lw=1, + edgecolor=LN, + facecolor=fill, + ) + ax.add_patch(rect) + ax.text( + x + w / 2, + y + row_h / 2, + val, + ha="center", + va="center", + fontsize=FS, + ) + + # Max per column annotation + max_y = start_y - _DATA_STATE_COLS * row_h - 0.1 + col_maxes = ["max=200", "max=70", "max=40"] + for idx, label in enumerate(col_maxes): + ax.text( + start_x + 0.7 + (idx + 0.5) * col_w, + max_y, + label, + fontsize=7, + ha="center", + va="top", + fontweight="bold", + color="#333", + ) + + # Arrow + ax.annotate( + "", + xy=(5.0, 4.8), + xytext=(4.2, 4.8), + arrowprops={"arrowstyle": "->", "color": LN, "lw": 2}, + ) + ax.text( + 4.6, + 5.0, + "rᵢⱼ = max - aᵢⱼ", + fontsize=8, + ha="center", + va="bottom", + fontweight="bold", + ) + + +def _draw_regret_table( + ax: Axes, + start_y: float, + row_h: float, +) -> None: + """Draw the regret matrix (right side of regret fig).""" + ax.text( + 7.5, + 6.3, + "Krok 2: Macierz żalu", + fontsize=9, + fontweight="bold", + ha="center", + va="center", + ) + + regret_data = [ + ["A₁", "0", "20", "140"], + ["A₂", "120", "0", "0"], + ["A₃", "170", "40", "10"], + ] + headers2 = ["", "S₁", "S₂", "S₃", "max rᵢ"] + start_x2 = 5.3 + + for j, h in enumerate(headers2): + w = 0.7 if j == 0 else (0.9 if j < _REGRET_HEADER_COLS else 1.0) + x = start_x2 + if j == 0: + x = start_x2 + elif j <= _DATA_STATE_COLS: + x = start_x2 + 0.7 + (j - 1) * 0.9 + else: + x = start_x2 + 0.7 + _DATA_STATE_COLS * 0.9 + rect = mpatches.Rectangle( + (x, start_y), + w, + row_h, + lw=1, + edgecolor=LN, + facecolor=(GRAY2 if j < _REGRET_HEADER_COLS else GRAY3), + ) + ax.add_patch(rect) + ax.text( + x + w / 2, + start_y + row_h / 2, + h, + ha="center", + va="center", + fontsize=FS, + fontweight="bold", + ) + + max_regrets = [140, 120, 170] + for i, row in enumerate(regret_data): + y = start_y - (i + 1) * row_h + for j, val in enumerate(row): + w = 0.7 if j == 0 else 0.9 + x = start_x2 + (0 if j == 0 else 0.7 + (j - 1) * 0.9) + fill = GRAY4 if j == 0 else "white" + if j > 0 and int(val) == max_regrets[i]: + fill = GRAY2 + rect = mpatches.Rectangle( + (x, y), + w, + row_h, + lw=1, + edgecolor=LN, + facecolor=fill, + ) + ax.add_patch(rect) + fw = "bold" if (j > 0 and int(val) == max_regrets[i]) else "normal" + ax.text( + x + w / 2, + y + row_h / 2, + val, + ha="center", + va="center", + fontsize=FS, + fontweight=fw, + ) + + # Max regret column + x = start_x2 + 0.7 + _DATA_STATE_COLS * 0.9 + w = 1.0 + is_winner = max_regrets[i] == min(max_regrets) + fill = "#C8C8C8" if is_winner else GRAY1 + rect = mpatches.Rectangle( + (x, y), + w, + row_h, + lw=1.5 if is_winner else 1, + edgecolor=LN, + facecolor=fill, + ) + ax.add_patch(rect) + marker = " ★" if is_winner else "" + ax.text( + x + w / 2, + y + row_h / 2, + f"{max_regrets[i]}{marker}", + ha="center", + va="center", + fontsize=FS, + fontweight="bold", + ) + + +def draw_regret_matrix() -> None: + """Draw the regret matrix construction diagram.""" + fig, ax = plt.subplots(1, 1, figsize=(8.27, 5)) + ax.axis("off") + ax.set_xlim(0, 10) + ax.set_ylim(0, 7) + ax.set_title( + "Kryterium Savage'a \u2014 budowa macierzy żalu", + fontsize=FS_TITLE + 1, + fontweight="bold", + pad=10, + ) + + start_y = 5.5 + row_h = 0.55 + + _draw_original_payoff(ax, start_y, row_h) + _draw_regret_table(ax, start_y, row_h) + + # Bottom conclusion + ax.text( + 5.0, + 2.8, + "Krok 3: Wybierz min z max żalu" " → A₂ (max żal = 120)", + fontsize=10, + ha="center", + va="center", + fontweight="bold", + bbox={ + "boxstyle": "round,pad=0.3", + "facecolor": GRAY1, + "edgecolor": LN, + "lw": 1.5, + }, + ) + + # Interpretation examples + ax.text( + 5.0, + 2.0, + "Interpretacja żalu: r₁₃ = 140 oznacza:\n" + "\u201eGdyby nastąpił S₃ (zła koniunktura)," + " a wybrałbym A₁,\n" + "żałowałbym, bo najlepszą opcją byłoby" + " A₂ z wynikiem 40 \u2014 traciłbym 140\u201d", + fontsize=7.5, + ha="center", + va="center", + style="italic", + bbox={ + "boxstyle": "round,pad=0.3", + "facecolor": GRAY4, + "edgecolor": GRAY3, + "lw": 0.8, + }, + ) + + # Mnemonic + ax.text( + 5.0, + 0.8, + "Mnemonik: Savage = \u201eŻal jak nóż\u201d\n" + "Maksymalny żal to nóż " + "\u2014 wybierz opcję z NAJMNIEJSZYM nożem", + fontsize=8, + ha="center", + va="center", + fontweight="bold", + bbox={ + "boxstyle": "round,pad=0.3", + "facecolor": "white", + "edgecolor": LN, + "lw": 1, + }, + ) + + plt.tight_layout() + outpath = str(Path(OUTPUT_DIR) / "q31_regret_matrix.png") + fig.savefig(outpath, dpi=DPI, bbox_inches="tight", facecolor=BG) + plt.close(fig) + _logger.info(" Saved: %s", outpath) diff --git a/python_pkg/praca_magisterska_video/generate_images/_q9_basics.py b/python_pkg/praca_magisterska_video/generate_images/_q9_basics.py new file mode 100644 index 0000000..2a45053 --- /dev/null +++ b/python_pkg/praca_magisterska_video/generate_images/_q9_basics.py @@ -0,0 +1,448 @@ +"""Q9 diagrams 1-6: process/thread basics, memory, states, PCB, speed.""" + +from __future__ import annotations + +from _q9_common import ( + FS, + FS_LABEL, + FS_SMALL, + FS_TITLE, + GRAY1, + GRAY2, + GRAY3, + GRAY4, + GRAY5, + LN, + draw_arrow, + draw_box, + draw_table, + save_fig, +) +import matplotlib.pyplot as plt + + +# ============================================================ +# 1. Process vs Thread comparison table +# ============================================================ +def gen_process_vs_thread() -> None: + """Gen process vs thread.""" + fig, ax = plt.subplots(figsize=(7.5, 4.5)) + ax.set_xlim(0, 10) + ax.set_ylim(-4.5, 1.5) + ax.set_aspect("auto") + ax.axis("off") + ax.set_title( + "Proces vs Wątek — porównanie", fontsize=FS_TITLE, fontweight="bold", pad=10 + ) + + headers = ["Cecha", "Proces", "Wątek"] + col_w = [2.5, 3.5, 3.5] + rows = [ + ["Pamięć", "Własna, izolowana", "Współdzielona (heap)"], + ["Tworzenie", "~1-10 ms", "~10-100 μs (100x szybciej)"], + ["Przełączanie", "~1-5 μs (TLB flush)", "~0.1-0.5 μs (10x)"], + ["Komunikacja", "IPC (pipe, socket, shm)", "Bezpośrednia (wspólna pam.)"], + ["Izolacja", "Pełna — awaria izolowana", "Brak — może zabić proces"], + ["Zastosowanie", "Bezpieczeństwo, izolacja", "Wydajność, współdzielenie"], + ] + draw_table( + ax, + headers, + rows, + x0=0.25, + y0=0.8, + col_widths=col_w, + row_h=0.55, + fontsize=7.5, + header_fontsize=FS_LABEL, + ) + + # Analogy at bottom + ax.text( + 5.0, + -4.2, + "Analogia: Proces = mieszkanie (własny adres) " + "Wątek = pokój w mieszkaniu (wspólna kuchnia = heap)", + ha="center", + fontsize=FS, + style="italic", + bbox={"boxstyle": "round,pad=0.3", "facecolor": GRAY4, "edgecolor": GRAY3}, + ) + + save_fig(fig, "q9_process_vs_thread.png") + + +# ============================================================ +# 2. Memory segments layout +# ============================================================ +def gen_memory_layout() -> None: + """Gen memory layout.""" + fig, ax = plt.subplots(figsize=(6, 5)) + ax.set_xlim(0, 10) + ax.set_ylim(0, 8) + ax.set_aspect("auto") + ax.axis("off") + ax.set_title( + "Segmenty pamięci procesu", fontsize=FS_TITLE, fontweight="bold", pad=10 + ) + + segments = [ + ("STACK ↓", "zmienne lokalne, adresy\npowrotu (każdy wątek WŁASNY)", GRAY1), + ("...", "(wolna przestrzeń)", "white"), + ("HEAP ↑", "malloc/new — dynamiczna\nalokacja (współdzielony)", GRAY4), + ("BSS", "zmienne globalne\nniezainicjalizowane (zerowane)", GRAY2), + ("DATA", "zmienne globalne\nzainicjalizowane", GRAY3), + ("TEXT", "kod maszynowy\n(read-only, współdzielony)", GRAY5), + ] + bx, bw = 2.0, 2.5 + seg_h = 0.9 + gap = 0.05 + top_y = 7.0 + + for i, (name, desc, color) in enumerate(segments): + y = top_y - i * (seg_h + gap) + draw_box( + ax, + bx, + y, + bw, + seg_h, + name, + fill=color, + fontsize=FS_LABEL, + fontweight="bold", + rounded=False, + ) + ax.text(bx + bw + 0.3, y + seg_h / 2, desc, fontsize=7.5, va="center") + + # Address labels + ax.text( + bx - 0.2, + top_y + seg_h / 2, + "wysoki\nadres", + fontsize=FS_SMALL, + va="center", + ha="right", + style="italic", + ) + bottom_y = top_y - 5 * (seg_h + gap) + ax.text( + bx - 0.2, + bottom_y + seg_h / 2, + "niski\nadres", + fontsize=FS_SMALL, + va="center", + ha="right", + style="italic", + ) + + # Arrows for growth + ax.annotate( + "", + xy=(bx - 0.5, top_y - 0.1), + xytext=(bx - 0.5, top_y + seg_h + 0.1), + arrowprops={"arrowstyle": "->", "lw": 1.5, "color": LN}, + ) + ax.text(bx - 0.9, top_y + 0.4, "rośnie\nw dół", fontsize=FS_SMALL, ha="center") + + heap_y = top_y - 2 * (seg_h + gap) + ax.annotate( + "", + xy=(bx - 0.5, heap_y + seg_h + 0.1), + xytext=(bx - 0.5, heap_y - 0.1), + arrowprops={"arrowstyle": "->", "lw": 1.5, "color": LN}, + ) + ax.text(bx - 0.9, heap_y + 0.5, "rośnie\nw górę", fontsize=FS_SMALL, ha="center") + + save_fig(fig, "q9_memory_layout.png") + + +# ============================================================ +# 3. Process states diagram +# ============================================================ +def gen_process_states() -> None: + """Gen process states.""" + fig, ax = plt.subplots(figsize=(7, 3.5)) + ax.set_xlim(0, 12) + ax.set_ylim(0, 5) + ax.set_aspect("auto") + ax.axis("off") + ax.set_title( + "Stany procesu — diagram przejść", fontsize=FS_TITLE, fontweight="bold", pad=10 + ) + + states = { + "NEW": (1.0, 2.5), + "READY": (3.5, 2.5), + "RUNNING": (6.5, 2.5), + "BLOCKED": (6.5, 0.5), + "TERMINATED": (10.0, 2.5), + } + fills = { + "NEW": GRAY4, + "READY": GRAY1, + "RUNNING": GRAY3, + "BLOCKED": GRAY2, + "TERMINATED": GRAY5, + } + bw, bh = 1.8, 0.9 + for name, (x, y) in states.items(): + draw_box( + ax, + x, + y, + bw, + bh, + name, + fill=fills[name], + fontsize=FS_LABEL, + fontweight="bold", + ) + + # Transitions + transitions = [ + ("NEW", "READY", "admit"), + ("READY", "RUNNING", "dispatch\n(scheduler)"), + ("RUNNING", "TERMINATED", "exit"), + ("RUNNING", "BLOCKED", "I/O wait"), + ] + for src, dst, label in transitions: + sx, sy = states[src] + dx, dy = states[dst] + if sy == dy: # horizontal + draw_arrow(ax, sx + bw, sy + bh / 2, dx, dy + bh / 2, lw=1.5) + mx = (sx + bw + dx) / 2 + ax.text( + mx, + sy + bh / 2 + 0.25, + label, + fontsize=FS_SMALL, + ha="center", + va="bottom", + ) + else: # vertical + draw_arrow(ax, sx + bw / 2, sy, dx + bw / 2, dy + bh, lw=1.5) + ax.text( + sx + bw + 0.2, + (sy + dy + bh) / 2, + label, + fontsize=FS_SMALL, + ha="left", + va="center", + ) + + # BLOCKED → READY + bx, by = states["BLOCKED"] + rx, ry = states["READY"] + ax.annotate( + "", + xy=(rx + bw / 2, ry), + xytext=(bx - 0.3, by + bh / 2), + arrowprops={ + "arrowstyle": "->", + "lw": 1.5, + "color": LN, + "connectionstyle": "arc3,rad=0.3", + }, + ) + ax.text(3.5, 0.7, "I/O done", fontsize=FS_SMALL, ha="center") + + # RUNNING → READY (preemption) + rux, ruy = states["RUNNING"] + draw_arrow(ax, rux, ruy + bh, rx + bw, ry + bh, lw=1.2) + ax.text(5.0, 3.7, "preempt /\ntimeout", fontsize=FS_SMALL, ha="center") + + save_fig(fig, "q9_process_states.png") + + +# ============================================================ +# 4. Thread structure within process +# ============================================================ +def gen_thread_structure() -> None: + """Gen thread structure.""" + fig, ax = plt.subplots(figsize=(8, 4.5)) + ax.set_xlim(0, 10) + ax.set_ylim(0, 6) + ax.set_aspect("auto") + ax.axis("off") + ax.set_title( + "Wątki wewnątrz procesu (PID=42)", fontsize=FS_TITLE, fontweight="bold", pad=10 + ) + + # Shared memory region + draw_box(ax, 0.5, 3.5, 9.0, 1.8, "", fill=GRAY1, rounded=False, lw=2) + ax.text(5.0, 5.0, "WSPÓŁDZIELONE", fontsize=FS, fontweight="bold", ha="center") + + labels_shared = ["TEXT", "DATA", "BSS", "HEAP", "pliki", "PID"] + for i, lab in enumerate(labels_shared): + x = 1.0 + i * 1.4 + draw_box( + ax, + x, + 3.8, + 1.1, + 0.6, + lab, + fill=GRAY3, + fontsize=FS, + fontweight="bold", + rounded=False, + ) + ax.text(x + 0.55, 4.6, lab, fontsize=FS_SMALL, ha="center", color="#555555") + + # Per-thread regions + draw_box( + ax, 0.5, 0.5, 9.0, 2.7, "", fill="white", rounded=False, lw=2, linestyle="--" + ) + ax.text( + 5.0, 2.95, "PRYWATNE (każdy wątek)", fontsize=FS, fontweight="bold", ha="center" + ) + + for i in range(3): + x = 1.0 + i * 3.0 + tid = i + 1 + draw_box(ax, x, 0.7, 2.3, 2.0, "", fill=GRAY4, rounded=False) + ax.text( + x + 1.15, + 2.4, + f"Wątek {tid}", + fontsize=FS_LABEL, + fontweight="bold", + ha="center", + ) + items = [f"stos_{tid}", f"rejestry_{tid}", f"PC_{tid}", f"TID={40 + tid}"] + for j, item in enumerate(items): + ax.text( + x + 1.15, + 2.0 - j * 0.35, + item, + fontsize=FS_SMALL, + ha="center", + family="monospace", + ) + + save_fig(fig, "q9_thread_structure.png") + + +# ============================================================ +# 5. PCB structure +# ============================================================ +def gen_pcb_structure() -> None: + """Gen pcb structure.""" + fig, ax = plt.subplots(figsize=(5, 3.5)) + ax.set_xlim(0, 8) + ax.set_ylim(0, 5.5) + ax.set_aspect("auto") + ax.axis("off") + ax.set_title( + "PCB (Process Control Block)", fontsize=FS_TITLE, fontweight="bold", pad=10 + ) + + fields = [ + ("PID", "42"), + ("Stan", "READY / RUNNING / BLOCKED"), + ("Rejestry CPU", "EAX, EBX, ESP, EIP ..."), + ("Tablice stron", "mapowanie wirtualne → fizyczne"), + ("Otwarte pliki", "fd[0], fd[1], fd[2] ..."), + ("Priorytety", "nice value, scheduling class"), + ("Statystyki", "CPU time, I/O count"), + ] + + top_y = 4.8 + for i, (field, value) in enumerate(fields): + y = top_y - i * 0.55 + draw_box( + ax, + 0.5, + y, + 2.2, + 0.45, + field, + fill=GRAY2, + fontsize=FS, + fontweight="bold", + rounded=False, + ) + draw_box(ax, 2.7, y, 4.5, 0.45, value, fill=GRAY4, fontsize=FS, rounded=False) + + ax.text( + 4.0, + 0.3, + "Context switch = zapisz PCB starego → wczytaj PCB nowego", + fontsize=FS_SMALL, + ha="center", + style="italic", + ) + + save_fig(fig, "q9_pcb_structure.png") + + +# ============================================================ +# 6. Speed comparison +# ============================================================ +def gen_speed_comparison() -> None: + """Gen speed comparison.""" + fig, axes = plt.subplots(1, 2, figsize=(9, 3.5)) + fig.suptitle( + "Szybkość — procesy vs wątki (benchmarki Linux)", + fontsize=FS_TITLE, + fontweight="bold", + ) + + # Creation time panel + ax = axes[0] + ops = ["fork()\n(nowy proces)", "pthread_create()\n(nowy wątek)"] + times = [3.0, 0.05] # ms + colors = [GRAY3, GRAY1] + bars = ax.barh(ops, times, color=colors, edgecolor=LN, height=0.5, linewidth=1.2) + ax.set_xlabel("Czas [ms]", fontsize=FS) + ax.set_title("Tworzenie", fontsize=FS_LABEL, fontweight="bold") + ax.set_xlim(0, 4.5) + for bar, t in zip(bars, times, strict=False): + ax.text( + bar.get_width() + 0.1, + bar.get_y() + bar.get_height() / 2, + f"{t} ms", + va="center", + fontsize=FS, + ) + ax.text( + 2.5, + -0.6, + "~100x szybciej", + fontsize=FS, + ha="center", + fontweight="bold", + transform=ax.get_xaxis_transform(), + ) + ax.tick_params(labelsize=FS) + + # Right: context switch + ax = axes[1] + ops2 = ["Proces→Proces\n(TLB flush)", "Wątek→Wątek\n(TLB warm)"] + times2 = [3000, 300] # ns + bars2 = ax.barh(ops2, times2, color=colors, edgecolor=LN, height=0.5, linewidth=1.2) + ax.set_xlabel("Czas [ns]", fontsize=FS) + ax.set_title("Przełączanie kontekstu", fontsize=FS_LABEL, fontweight="bold") + ax.set_xlim(0, 4500) + for bar, t in zip(bars2, times2, strict=False): + ax.text( + bar.get_width() + 50, + bar.get_y() + bar.get_height() / 2, + f"{t} ns", + va="center", + fontsize=FS, + ) + ax.text( + 2500, + -0.6, + "~10x szybciej", + fontsize=FS, + ha="center", + fontweight="bold", + transform=ax.get_xaxis_transform(), + ) + ax.tick_params(labelsize=FS) + + fig.tight_layout(rect=[0, 0.05, 1, 0.92]) + save_fig(fig, "q9_speed_comparison.png") diff --git a/python_pkg/praca_magisterska_video/generate_images/_q9_classic_sync.py b/python_pkg/praca_magisterska_video/generate_images/_q9_classic_sync.py new file mode 100644 index 0000000..4932505 --- /dev/null +++ b/python_pkg/praca_magisterska_video/generate_images/_q9_classic_sync.py @@ -0,0 +1,420 @@ +"""Q9 diagrams 14-16: classic sync problems, mechanism comparison, semaphore.""" + +from __future__ import annotations + +from _q9_common import ( + FS, + FS_LABEL, + FS_SMALL, + FS_TITLE, + GRAY1, + GRAY2, + GRAY3, + GRAY4, + GRAY5, + LN, + OCCUPIED_SLOTS, + draw_arrow, + draw_box, + draw_table, + save_fig, +) +import matplotlib.pyplot as plt +import numpy as np + + +# ============================================================ +# 14. Bounded buffer + readers-writers + philosophers +# ============================================================ +def _draw_bounded_buffer_panel(ax: plt.Axes) -> None: + """Draw the bounded-buffer (producer-consumer) panel.""" + ax.set_xlim(0, 8) + ax.set_ylim(0, 7) + ax.set_aspect("auto") + ax.axis("off") + ax.set_title( + "Producent-Konsument\n(Bounded Buffer, N=4)", fontsize=FS, fontweight="bold" + ) + + draw_box( + ax, + 0.2, + 4.0, + 2.0, + 1.2, + "Producent\nP(empty)\nP(mutex)\nwstaw()\nV(mutex)\nV(full)", + fill=GRAY1, + fontsize=5.5, + ) + items = ["A", "B", "", ""] + for i, item in enumerate(items): + x = 2.8 + i * 0.9 + fill = GRAY3 if item else "white" + draw_box( + ax, + x, + 4.3, + 0.9, + 0.7, + item, + fill=fill, + fontsize=FS, + fontweight="bold", + rounded=False, + ) + ax.text(4.6, 5.2, "Bufor (N=4)", fontsize=FS_SMALL, ha="center", fontweight="bold") + draw_box( + ax, + 6.0, + 4.0, + 2.0, + 1.2, + "Konsument\nP(full)\nP(mutex)\npobierz()\nV(mutex)\nV(empty)", + fill=GRAY4, + fontsize=5.5, + ) + draw_arrow(ax, 2.2, 4.6, 2.8, 4.65, lw=1.2) + draw_arrow(ax, 6.4, 4.65, 6.0, 4.6, lw=1.2) + + sems = [("mutex = 1", GRAY2), ("empty = N", GRAY1), ("full = 0", GRAY3)] + for i, (s, c) in enumerate(sems): + draw_box( + ax, + 2.0, + 2.5 - i * 0.6, + 4.0, + 0.45, + s, + fill=c, + fontsize=FS_SMALL, + fontweight="bold", + ) + + ax.text( + 4.0, + 0.5, + "KOLEJNOŚĆ: P(empty/full)\nPRZED P(mutex)!\nOdwrotnie = DEADLOCK", + fontsize=5.5, + ha="center", + fontweight="bold", + color="#C62828", + bbox={"boxstyle": "round", "facecolor": "#F8D7DA", "edgecolor": "#C62828"}, + ) + + +def _draw_readers_writers_panel(ax: plt.Axes) -> None: + """Draw the readers-writers panel.""" + ax.set_xlim(0, 8) + ax.set_ylim(0, 7) + ax.set_aspect("auto") + ax.axis("off") + ax.set_title( + "Czytelnicy-Pisarze\n(Readers-Writers)", fontsize=FS, fontweight="bold" + ) + + draw_box( + ax, + 2.5, + 3.5, + 3.0, + 1.5, + "Dane\n(współdzielone)", + fill=GRAY2, + fontsize=FS, + fontweight="bold", + ) + + for i in range(3): + x = 0.3 + i * 1.0 + draw_box( + ax, + x, + 5.5, + 0.8, + 0.7, + f"R{i + 1}", + fill=GRAY4, + fontsize=FS, + fontweight="bold", + ) + draw_arrow(ax, x + 0.4, 5.5, 3.0 + i * 0.5, 5.0, lw=1) + + ax.text( + 1.5, + 6.5, + "Czytelnicy (wielu naraz)", + fontsize=FS_SMALL, + ha="center", + fontweight="bold", + ) + + draw_box( + ax, 5.5, 5.5, 1.5, 0.7, "Pisarz", fill=GRAY5, fontsize=FS, fontweight="bold" + ) + draw_arrow(ax, 6.25, 5.5, 5.0, 5.0, lw=1.5) + ax.text( + 6.25, + 6.5, + "WYŁĄCZNY", + fontsize=FS_SMALL, + ha="center", + fontweight="bold", + color="#C62828", + ) + + rules = [ + "Wielu czytelników = OK", + "Jeden pisarz = wyłączny", + "Czytelnik + Pisarz = ✗", + "Problem: pisarze głodują", + ] + for i, r in enumerate(rules): + ax.text(4.0, 2.5 - i * 0.45, r, fontsize=FS_SMALL, ha="center") + + ax.text( + 4.0, + 0.5, + "Rozwiązanie:\nrw_mutex + count_mutex\n+ zmienna readers", + fontsize=5.5, + ha="center", + bbox={"boxstyle": "round,pad=0.2", "facecolor": GRAY4, "edgecolor": GRAY3}, + ) + + +def _draw_philosophers_panel(ax: plt.Axes) -> None: + """Draw the dining-philosophers panel.""" + ax.set_xlim(0, 8) + ax.set_ylim(0, 7) + ax.set_aspect("auto") + ax.axis("off") + ax.set_title( + "Ucztujący filozofowie\n(Dining Philosophers)", fontsize=FS, fontweight="bold" + ) + + cx, cy, r = 4.0, 3.8, 1.8 + table = plt.Circle((cx, cy), 0.8, fill=True, facecolor=GRAY2, edgecolor=LN, lw=1.5) + ax.add_patch(table) + ax.text(cx, cy, "Stół", fontsize=FS, ha="center", fontweight="bold") + + for i in range(5): + angle = np.pi / 2 + i * 2 * np.pi / 5 + px = cx + r * np.cos(angle) + py = cy + r * np.sin(angle) + circle = plt.Circle( + (px, py), 0.35, fill=True, facecolor=GRAY1, edgecolor=LN, lw=1.2 + ) + ax.add_patch(circle) + ax.text( + px, py, f"F{i}", ha="center", va="center", fontsize=FS, fontweight="bold" + ) + + fork_angle = np.pi / 2 + (i + 0.5) * 2 * np.pi / 5 + fx = cx + (r * 0.6) * np.cos(fork_angle) + fy = cy + (r * 0.6) * np.sin(fork_angle) + ax.plot( + [fx - 0.1, fx + 0.1], + [fy - 0.15, fy + 0.15], + color=LN, + lw=2.5, + solid_capstyle="round", + ) + ax.text(fx + 0.2, fy + 0.15, f"w{i}", fontsize=5, color="#555555") + + rules = [ + "Jedzenie = 2 widelce", + "Naiwne → DEADLOCK", + "Fix: F4 bierze odwrotnie", + "Alt: semafor(4)", + ] + for i, r in enumerate(rules): + ax.text(4.0, 1.2 - i * 0.35, r, fontsize=FS_SMALL, ha="center") + + +def gen_classic_problems() -> None: + """Gen classic problems.""" + fig, axes = plt.subplots(1, 3, figsize=(12, 5)) + fig.suptitle( + "Klasyczne problemy synchronizacji", fontsize=FS_TITLE, fontweight="bold" + ) + + _draw_bounded_buffer_panel(axes[0]) + _draw_readers_writers_panel(axes[1]) + _draw_philosophers_panel(axes[2]) + + fig.tight_layout(rect=[0, 0, 1, 0.88]) + save_fig(fig, "q9_classic_problems.png") + + +# ============================================================ +# 15. Sync mechanisms comparison + mutex/sem/spinlock +# ============================================================ +def gen_sync_comparison() -> None: + """Gen sync comparison.""" + fig, axes = plt.subplots(2, 1, figsize=(9, 7)) + + # Top: comparison table + ax = axes[0] + ax.set_xlim(0, 11.5) + ax.set_ylim(-5, 1) + ax.set_aspect("auto") + ax.axis("off") + ax.set_title( + "Mechanizmy synchronizacji — porównanie", + fontsize=FS_TITLE, + fontweight="bold", + pad=10, + ) + + headers = ["Mechanizm", "Opis", "Kiedy używać"] + col_w = [2.5, 4.5, 4.0] + rows = [ + ["Mutex", "Zamek: 1 wątek w sekcji", "Sekcja krytyczna"], + ["Semafor(n)", "Licznik: max n wątków", "Ograniczone zasoby (n miejsc)"], + ["Monitor", "Obiekt z wbudowanym mutex", "Java synchronized"], + ["Cond. Variable", "wait()/signal() na warunek", "Producent-konsument"], + ["Spinlock", "Aktywne czekanie (busy-wait)", "Bardzo krótkie sekcje (<1 μs)"], + ["RW Lock", "Wielu czytelników LUB 1 pisarz", "Bazy danych, cache"], + ["Barrier", "Czekaj aż wszyscy dotrą", "Obliczenia równoległe"], + ] + draw_table( + ax, headers, rows, x0=0.25, y0=0.5, col_widths=col_w, row_h=0.5, fontsize=7 + ) + + # Bottom: mutex vs semafor vs spinlock + ax = axes[1] + ax.set_xlim(0, 12) + ax.set_ylim(0, 5) + ax.set_aspect("auto") + ax.axis("off") + ax.set_title( + "Mutex vs Semafor vs Spinlock", fontsize=FS_TITLE, fontweight="bold", pad=5 + ) + + # Mutex + draw_box(ax, 0.3, 2.5, 3.5, 2.0, "", fill=GRAY4) + ax.text(2.05, 4.2, "MUTEX", fontsize=FS_LABEL, fontweight="bold", ha="center") + ax.text(2.05, 3.6, "= klucz do łazienki\n(1 osoba)", fontsize=FS, ha="center") + ax.text( + 2.05, + 2.8, + "Wątek ZASYPIA gdy czeka\nOS go obudzi (~μs)", + fontsize=FS_SMALL, + ha="center", + style="italic", + ) + + # Semafor + draw_box(ax, 4.3, 2.5, 3.5, 2.0, "", fill=GRAY1) + ax.text(6.05, 4.2, "SEMAFOR(n)", fontsize=FS_LABEL, fontweight="bold", ha="center") + ax.text( + 6.05, 3.6, "= parking na n miejsc\n(n wątków naraz)", fontsize=FS, ha="center" + ) + ax.text( + 6.05, + 2.8, + "Semafor(1) = mutex\nP() = zmniejsz, V() = zwiększ", + fontsize=FS_SMALL, + ha="center", + style="italic", + ) + + # Spinlock + draw_box(ax, 8.3, 2.5, 3.5, 2.0, "", fill=GRAY2) + ax.text(10.05, 4.2, "SPINLOCK", fontsize=FS_LABEL, fontweight="bold", ha="center") + ax.text( + 10.05, 3.6, "= obrotowe drzwi\n(kręcisz się w kółko)", fontsize=FS, ha="center" + ) + ax.text( + 10.05, + 2.8, + "Wątek KRĘCI się w pętli\nLepszy gdy sekcja < 1 μs", + fontsize=FS_SMALL, + ha="center", + style="italic", + ) + + # Dividing rule + ax.text( + 6.0, + 1.5, + "Reguła kciuka: sekcja > 1 μs → MUTEX | " + "sekcja < 1 μs → SPINLOCK | n jednocześnie → SEMAFOR(n)", + fontsize=FS, + ha="center", + fontweight="bold", + bbox={"boxstyle": "round,pad=0.3", "facecolor": GRAY4, "edgecolor": GRAY3}, + ) + + fig.tight_layout() + save_fig(fig, "q9_sync_comparison.png") + + +# ============================================================ +# 16. Semaphore concept diagram +# ============================================================ +def gen_semaphore_concept() -> None: + """Gen semaphore concept.""" + fig, ax = plt.subplots(figsize=(6, 3)) + ax.set_xlim(0, 10) + ax.set_ylim(0, 5) + ax.set_aspect("auto") + ax.axis("off") + ax.set_title( + "Semafor — koncepcja (parking na 3 miejsca)", + fontsize=FS_TITLE, + fontweight="bold", + pad=10, + ) + + # Parking slots + for i in range(3): + x = 2.0 + i * 2.0 + occupied = i < OCCUPIED_SLOTS + fill = GRAY3 if occupied else "white" + label = f"Wątek {i + 1}" if occupied else "(wolne)" + draw_box( + ax, + x, + 2.5, + 1.5, + 1.2, + label, + fill=fill, + fontsize=FS, + fontweight="bold" if occupied else "normal", + rounded=False, + ) + + ax.text( + 5.0, + 4.2, + "semafor(3): counter = 1 (jedno wolne miejsce)", + fontsize=FS, + ha="center", + fontweight="bold", + ) + + # Waiting thread + draw_box( + ax, + 0.2, + 0.5, + 1.5, + 0.8, + "Wątek 4\nP() → czeka", + fill="#F8D7DA", + fontsize=FS_SMALL, + ) + draw_arrow(ax, 1.7, 0.9, 2.0, 2.5, lw=1.2, color="#C62828") + + ax.text( + 5.0, + 0.6, + "P() = counter-- (jeśli 0 → czekaj)\nV() = counter++ (obudź czekającego)", + fontsize=FS, + ha="center", + family="monospace", + bbox={"boxstyle": "round,pad=0.2", "facecolor": GRAY4, "edgecolor": GRAY3}, + ) + + save_fig(fig, "q9_semaphore_concept.png") diff --git a/python_pkg/praca_magisterska_video/generate_images/_q9_common.py b/python_pkg/praca_magisterska_video/generate_images/_q9_common.py new file mode 100644 index 0000000..6a714ce --- /dev/null +++ b/python_pkg/praca_magisterska_video/generate_images/_q9_common.py @@ -0,0 +1,200 @@ +"""Common utilities and constants for Q9 diagram generation. + +Monochrome, A4-printable PNGs (300 DPI). +""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import TYPE_CHECKING + +import matplotlib as mpl + +mpl.use("Agg") + +import matplotlib.patches as mpatches +from matplotlib.patches import FancyBboxPatch +import matplotlib.pyplot as plt + +if TYPE_CHECKING: + from matplotlib.axes import Axes + from matplotlib.figure import Figure + +_logger = logging.getLogger(__name__) + +DPI = 300 +BG = "white" +LN = "black" +FS = 8 +FS_TITLE = 11 +FS_SMALL = 6.5 +FS_LABEL = 9 +OUTPUT_DIR = str(Path(__file__).resolve().parent / "img") +Path(OUTPUT_DIR).mkdir(parents=True, exist_ok=True) + +GRAY1 = "#E8E8E8" +GRAY2 = "#D0D0D0" +GRAY3 = "#B8B8B8" +GRAY4 = "#F5F5F5" +GRAY5 = "#C0C0C0" +OCCUPIED_SLOTS = 2 + + +def draw_box( + ax: Axes, + x: float, + y: float, + w: float, + h: float, + text: str, + fill: str = "white", + lw: float = 1.2, + fontsize: float = FS, + fontweight: str = "normal", + ha: str = "center", + va: str = "center", + *, + rounded: bool = True, + edgecolor: str = LN, + linestyle: str = "-", +) -> None: + """Draw box.""" + if rounded: + rect = FancyBboxPatch( + (x, y), + w, + h, + boxstyle="round,pad=0.05", + lw=lw, + edgecolor=edgecolor, + facecolor=fill, + linestyle=linestyle, + ) + else: + rect = mpatches.Rectangle( + (x, y), + w, + h, + lw=lw, + edgecolor=edgecolor, + facecolor=fill, + linestyle=linestyle, + ) + ax.add_patch(rect) + ax.text( + x + w / 2, + y + h / 2, + text, + ha=ha, + va=va, + fontsize=fontsize, + fontweight=fontweight, + wrap=True, + ) + + +def draw_arrow( + ax: Axes, + x1: float, + y1: float, + x2: float, + y2: float, + lw: float = 1.2, + style: str = "->", + color: str = LN, +) -> None: + """Draw arrow.""" + ax.annotate( + "", + xy=(x2, y2), + xytext=(x1, y1), + arrowprops={"arrowstyle": style, "color": color, "lw": lw}, + ) + + +def draw_double_arrow( + ax: Axes, + x1: float, + y1: float, + x2: float, + y2: float, + lw: float = 1.2, + color: str = LN, +) -> None: + """Draw double arrow.""" + ax.annotate( + "", + xy=(x2, y2), + xytext=(x1, y1), + arrowprops={"arrowstyle": "<->", "color": color, "lw": lw}, + ) + + +def save_fig(fig: Figure, name: str) -> None: + """Save fig.""" + path = str(Path(OUTPUT_DIR) / name) + fig.savefig(path, dpi=DPI, bbox_inches="tight", facecolor=BG, pad_inches=0.15) + plt.close(fig) + _logger.info(" Saved: %s", path) + + +def draw_table( + ax: Axes, + headers: list[str], + rows: list[list[str]], + x0: float, + y0: float, + col_widths: list[float], + row_h: float = 0.4, + header_fill: str = GRAY2, + row_fills: list[str] | None = None, + fontsize: float = FS, + header_fontsize: float | None = None, +) -> None: + """Draw a clean table on axes.""" + if header_fontsize is None: + header_fontsize = fontsize + len(headers) + len(rows) + sum(col_widths) + + # Header + cx = x0 + for j, hdr in enumerate(headers): + draw_box( + ax, + cx, + y0, + col_widths[j], + row_h, + hdr, + fill=header_fill, + fontsize=header_fontsize, + fontweight="bold", + rounded=False, + ) + cx += col_widths[j] + + # Rows + for i, row in enumerate(rows): + cy = y0 - (i + 1) * row_h + cx = x0 + fill = GRAY4 if (i % 2 == 0) else "white" + if row_fills and i < len(row_fills): + fill = row_fills[i] + for j, cell in enumerate(row): + fw = "bold" if j == 0 else "normal" + draw_box( + ax, + cx, + cy, + col_widths[j], + row_h, + cell, + fill=fill, + fontsize=fontsize, + fontweight=fw, + rounded=False, + ) + cx += col_widths[j] diff --git a/python_pkg/praca_magisterska_video/generate_images/_q9_ipc.py b/python_pkg/praca_magisterska_video/generate_images/_q9_ipc.py new file mode 100644 index 0000000..825372e --- /dev/null +++ b/python_pkg/praca_magisterska_video/generate_images/_q9_ipc.py @@ -0,0 +1,212 @@ +"""Q9 diagrams 7-9: IPC mechanisms and scenario tables.""" + +from __future__ import annotations + +from _q9_common import ( + FS, + FS_LABEL, + FS_SMALL, + FS_TITLE, + GRAY1, + GRAY2, + GRAY3, + GRAY4, + draw_arrow, + draw_box, + draw_double_arrow, + draw_table, + save_fig, +) +import matplotlib.pyplot as plt + + +# ============================================================ +# 7. Scenario table (when to use process vs thread) +# ============================================================ +def gen_scenario_table() -> None: + """Gen scenario table.""" + fig, ax = plt.subplots(figsize=(8.5, 4.5)) + ax.set_xlim(0, 11) + ax.set_ylim(-5.5, 1) + ax.set_aspect("auto") + ax.axis("off") + ax.set_title( + "Kiedy proces, kiedy wątek? — typowe scenariusze", + fontsize=FS_TITLE, + fontweight="bold", + pad=10, + ) + + headers = ["Scenariusz", "Wybór", "Dlaczego?"] + col_w = [3.5, 2.5, 4.5] + rows = [ + ["Serwer WWW (Apache)", "Proces", "izolacja klientów"], + ["Serwer WWW (nginx)", "Wątek / async", "szybkość, cooperacja"], + ["Przeglądarka (karty)", "Proces", "crash isolation"], + ["Przeglądarka (JS+render)", "Wątek", "współdzielony DOM"], + ["Gra (fizyka+rendering)", "Wątek", "współdzielony świat gry"], + ["Kompilacja (make -j8)", "Proces", "izolacja, prostota"], + ["Baza danych (zapytania)", "Wątek", "współdzielony cache"], + ["Microservices", "Proces (kontener)", "izolacja, deployment"], + ] + draw_table( + ax, headers, rows, x0=0.25, y0=0.5, col_widths=col_w, row_h=0.5, fontsize=7 + ) + + save_fig(fig, "q9_scenario_table.png") + + +# ============================================================ +# 8. IPC details: pipe, shared memory, socket (3-panel) +# ============================================================ +def gen_ipc_details() -> None: + """Gen ipc details.""" + fig, axes = plt.subplots(1, 3, figsize=(11, 3.5)) + fig.suptitle("Mechanizmy IPC — szczegóły", fontsize=FS_TITLE, fontweight="bold") + + # Panel 1: Pipe + ax = axes[0] + ax.set_xlim(0, 8) + ax.set_ylim(0, 5) + ax.set_aspect("auto") + ax.axis("off") + ax.set_title("Pipe (potok)", fontsize=FS_LABEL, fontweight="bold") + + draw_box(ax, 0.2, 2.0, 1.8, 1.2, "Proces A\n(ls)\nstdout", fill=GRAY1, fontsize=FS) + draw_box( + ax, + 3.0, + 2.0, + 1.8, + 1.2, + "Bufor\njądra\n(4 KB)", + fill=GRAY2, + fontsize=FS, + fontweight="bold", + ) + draw_box(ax, 5.8, 2.0, 1.8, 1.2, "Proces B\n(grep)\nstdin", fill=GRAY1, fontsize=FS) + draw_arrow(ax, 2.0, 2.6, 3.0, 2.6, lw=1.5) + ax.text(2.5, 3.0, "write()\nfd[1]", fontsize=FS_SMALL, ha="center") + draw_arrow(ax, 4.8, 2.6, 5.8, 2.6, lw=1.5) + ax.text(5.3, 3.0, "read()\nfd[0]", fontsize=FS_SMALL, ha="center") + ax.text( + 4.0, + 0.8, + "Jednokierunkowy\nBufor pełny → write() blokuje", + fontsize=FS_SMALL, + ha="center", + style="italic", + bbox={"boxstyle": "round,pad=0.2", "facecolor": GRAY4, "edgecolor": GRAY3}, + ) + + # Panel 2: Shared Memory + ax = axes[1] + ax.set_xlim(0, 8) + ax.set_ylim(0, 5) + ax.set_aspect("auto") + ax.axis("off") + ax.set_title("Shared Memory", fontsize=FS_LABEL, fontweight="bold") + + draw_box(ax, 0.3, 3.0, 2.2, 1.2, "Proces A\nstrona 7", fill=GRAY1, fontsize=FS) + draw_box(ax, 5.5, 3.0, 2.2, 1.2, "Proces B\nstrona 3", fill=GRAY1, fontsize=FS) + draw_box( + ax, + 2.8, + 1.0, + 2.4, + 1.2, + "RAM\nramka 42", + fill=GRAY3, + fontsize=FS, + fontweight="bold", + ) + draw_arrow(ax, 2.0, 3.0, 3.5, 2.2, lw=1.5) + draw_arrow(ax, 6.0, 3.0, 4.5, 2.2, lw=1.5) + ax.text( + 4.0, + 0.3, + "Zero kopiowania!\nA pisze → B widzi od razu\nWymaga synchronizacji (semafor)", + fontsize=FS_SMALL, + ha="center", + style="italic", + bbox={"boxstyle": "round,pad=0.2", "facecolor": GRAY4, "edgecolor": GRAY3}, + ) + + # Panel 3: Socket + ax = axes[2] + ax.set_xlim(0, 8) + ax.set_ylim(0, 5) + ax.set_aspect("auto") + ax.axis("off") + ax.set_title("Socket", fontsize=FS_LABEL, fontweight="bold") + + # Network socket + draw_box( + ax, 0.3, 3.2, 1.8, 0.9, "Klient", fill=GRAY1, fontsize=FS, fontweight="bold" + ) + draw_box( + ax, 5.5, 3.2, 1.8, 0.9, "Serwer", fill=GRAY1, fontsize=FS, fontweight="bold" + ) + draw_double_arrow(ax, 2.1, 3.65, 5.5, 3.65, lw=1.5) + ax.text(3.8, 4.3, "TCP/IP (sieciowy)", fontsize=FS, ha="center", fontweight="bold") + + # Unix socket + draw_box( + ax, 0.3, 1.3, 1.8, 0.9, "Proces A", fill=GRAY4, fontsize=FS, fontweight="bold" + ) + draw_box( + ax, 5.5, 1.3, 1.8, 0.9, "Proces B", fill=GRAY4, fontsize=FS, fontweight="bold" + ) + draw_double_arrow(ax, 2.1, 1.75, 5.5, 1.75, lw=1.5) + ax.text( + 3.8, + 2.4, + "Unix domain socket\n(/tmp/app.sock)", + fontsize=FS, + ha="center", + fontweight="bold", + ) + + ax.text( + 3.8, + 0.5, + "Dwukierunkowy\nNajbardziej uniwersalny IPC", + fontsize=FS_SMALL, + ha="center", + style="italic", + bbox={"boxstyle": "round,pad=0.2", "facecolor": GRAY4, "edgecolor": GRAY3}, + ) + + fig.tight_layout(rect=[0, 0, 1, 0.9]) + save_fig(fig, "q9_ipc_details.png") + + +# ============================================================ +# 9. IPC comparison table +# ============================================================ +def gen_ipc_table() -> None: + """Gen ipc table.""" + fig, ax = plt.subplots(figsize=(8.5, 3.5)) + ax.set_xlim(0, 11) + ax.set_ylim(-4.5, 1) + ax.set_aspect("auto") + ax.axis("off") + ax.set_title( + "Porównanie mechanizmów IPC", fontsize=FS_TITLE, fontweight="bold", pad=10 + ) + + headers = ["Mechanizm", "Kierunek", "Szybkość", "Zastosowanie"] + col_w = [2.5, 2.0, 2.5, 3.5] + rows = [ + ["Pipe", "jednokierunkowy", "średnia", "ls | grep"], + ["Named Pipe", "jednokierunkowy", "średnia", "demon → klient"], + ["Shared Memory", "dwukierunkowy", "NAJSZYBSZA", "video, bazy danych"], + ["Message Queue", "dwukierunkowy", "średnia", "wieloproducentowe"], + ["Socket", "dwukierunkowy", "wolna (sieć)", "klient-serwer"], + ["Signal", "jednokierunkowy", "natychmiastowa", "powiadomienia (nr)"], + ] + draw_table( + ax, headers, rows, x0=0.25, y0=0.5, col_widths=col_w, row_h=0.5, fontsize=7.5 + ) + + save_fig(fig, "q9_ipc_table.png") diff --git a/python_pkg/praca_magisterska_video/generate_images/_q9_race_deadlock.py b/python_pkg/praca_magisterska_video/generate_images/_q9_race_deadlock.py new file mode 100644 index 0000000..7166f07 --- /dev/null +++ b/python_pkg/praca_magisterska_video/generate_images/_q9_race_deadlock.py @@ -0,0 +1,404 @@ +"""Q9 diagrams 10-13: race conditions, deadlock, Coffman, starvation.""" + +from __future__ import annotations + +from _q9_common import ( + FS, + FS_LABEL, + FS_SMALL, + FS_TITLE, + GRAY1, + GRAY2, + GRAY3, + GRAY4, + GRAY5, + LN, + draw_arrow, + draw_box, + draw_table, + save_fig, +) +import matplotlib.pyplot as plt + + +# ============================================================ +# 10. Race condition (simple x + bank timeline) +# ============================================================ +def gen_race_condition() -> None: + """Gen race condition.""" + fig, axes = plt.subplots(1, 2, figsize=(11, 5)) + fig.suptitle( + "Wyścig (Race Condition) — przykłady", fontsize=FS_TITLE, fontweight="bold" + ) + + # Panel 1: simple x increment + ax = axes[0] + ax.set_xlim(0, 8) + ax.set_ylim(0, 7) + ax.set_aspect("auto") + ax.axis("off") + ax.set_title("Prosty wyścig: x = x + 1", fontsize=FS_LABEL, fontweight="bold") + + # Timeline + steps_a = ["czytaj x (=0)", "dodaj 1", "zapisz x (=1)"] + steps_b = ["czytaj x (=0)", "dodaj 1", "zapisz x (=1)"] + ax.text(2.0, 6.3, "Wątek A", fontsize=FS_LABEL, ha="center", fontweight="bold") + ax.text(6.0, 6.3, "Wątek B", fontsize=FS_LABEL, ha="center", fontweight="bold") + ax.plot([2, 2], [0.8, 6.0], color=LN, lw=1) + ax.plot([6, 6], [0.8, 6.0], color=LN, lw=1) + + for i, (sa, sb) in enumerate(zip(steps_a, steps_b, strict=False)): + y = 5.3 - i * 1.2 + draw_box(ax, 0.5, y, 3.0, 0.6, sa, fill=GRAY4, fontsize=FS) + draw_box(ax, 4.5, y - 0.3, 3.0, 0.6, sb, fill=GRAY1, fontsize=FS) + + ax.text( + 4.0, + 0.4, + "Wynik: x = 1 (powinno 2!)", + fontsize=FS, + ha="center", + fontweight="bold", + color="#C62828", + bbox={"boxstyle": "round", "facecolor": "#F8D7DA", "edgecolor": "#C62828"}, + ) + + # Panel 2: bank account + ax = axes[1] + ax.set_xlim(0, 10) + ax.set_ylim(0, 7) + ax.set_aspect("auto") + ax.axis("off") + ax.set_title("Konto bankowe: saldo = 1000 zł", fontsize=FS_LABEL, fontweight="bold") + + ax.text(2.5, 6.3, "Wątek A (+500)", fontsize=FS, ha="center", fontweight="bold") + ax.text(7.5, 6.3, "Wątek B (-200)", fontsize=FS, ha="center", fontweight="bold") + ax.plot([2.5, 2.5], [0.8, 6.0], color=LN, lw=1) + ax.plot([7.5, 7.5], [0.8, 6.0], color=LN, lw=1) + + events = [ + ("t1", "czytaj → 1000", "", 5.3), + ("t2", "", "czytaj → 1000", 4.6), + ("t3", "1000+500=1500", "", 3.9), + ("t4", "", "1000-200=800", 3.2), + ("t5", "zapisz 1500", "", 2.5), + ("t6", "", "zapisz 800 ✗", 1.8), + ] + for t, a, b, y in events: + ax.text(0.3, y + 0.15, t, fontsize=FS_SMALL, fontweight="bold", va="center") + if a: + draw_box(ax, 1.0, y, 3.0, 0.45, a, fill=GRAY4, fontsize=FS_SMALL) + if b: + fill = "#F8D7DA" if "✗" in b else GRAY1 + draw_box(ax, 6.0, y, 3.0, 0.45, b, fill=fill, fontsize=FS_SMALL) + + ax.text( + 5.0, + 0.4, + "Wynik: 800 zł (powinno 1300!)", + fontsize=FS, + ha="center", + fontweight="bold", + color="#C62828", + bbox={"boxstyle": "round", "facecolor": "#F8D7DA", "edgecolor": "#C62828"}, + ) + + fig.tight_layout(rect=[0, 0, 1, 0.9]) + save_fig(fig, "q9_race_condition.png") + + +# ============================================================ +# 11. Deadlock scenario + cycle +# ============================================================ +def gen_deadlock_scenario() -> None: + """Gen deadlock scenario.""" + fig, axes = plt.subplots(1, 2, figsize=(11, 4.5)) + fig.suptitle("Zakleszczenie (Deadlock)", fontsize=FS_TITLE, fontweight="bold") + + # Panel 1: timeline + ax = axes[0] + ax.set_xlim(0, 8) + ax.set_ylim(0, 6) + ax.set_aspect("auto") + ax.axis("off") + ax.set_title("Scenariusz z 2 mutexami", fontsize=FS_LABEL, fontweight="bold") + + ax.text(2.5, 5.3, "Wątek A", fontsize=FS_LABEL, ha="center", fontweight="bold") + ax.text(6.0, 5.3, "Wątek B", fontsize=FS_LABEL, ha="center", fontweight="bold") + + steps = [ + ("lock(mutex1) OK", "", "trzyma", False, 4.5), + ("", "lock(mutex2) OK", "trzyma", False, 3.7), + ("lock(mutex2) ...WAIT", "", "CZEKA!", True, 2.9), + ("", "lock(mutex1) ...WAIT", "CZEKA!", True, 2.1), + ] + for a_text, b_text, _note, is_wait, y in steps: + if a_text: + fill = "#F8D7DA" if is_wait else GRAY4 + draw_box(ax, 0.5, y, 3.3, 0.55, a_text, fill=fill, fontsize=FS_SMALL) + if b_text: + fill = "#F8D7DA" if is_wait else GRAY4 + draw_box(ax, 4.3, y, 3.3, 0.55, b_text, fill=fill, fontsize=FS_SMALL) + + ax.text( + 4.0, + 1.2, + "DEADLOCK!\nŻaden nie odpuści", + fontsize=FS, + ha="center", + fontweight="bold", + color="#C62828", + bbox={"boxstyle": "round", "facecolor": "#F8D7DA", "edgecolor": "#C62828"}, + ) + + # Panel 2: cycle diagram + ax = axes[1] + ax.set_xlim(0, 8) + ax.set_ylim(0, 6) + ax.set_aspect("auto") + ax.axis("off") + ax.set_title("Cykl oczekiwania", fontsize=FS_LABEL, fontweight="bold") + + # Thread boxes + draw_box( + ax, + 0.5, + 3.5, + 2.2, + 1.2, + "Wątek A\ntrzyma Mutex 1", + fill=GRAY1, + fontsize=FS, + fontweight="bold", + ) + draw_box( + ax, + 5.3, + 3.5, + 2.2, + 1.2, + "Wątek B\ntrzyma Mutex 2", + fill=GRAY1, + fontsize=FS, + fontweight="bold", + ) + + # Mutex boxes + draw_box( + ax, 0.5, 1.0, 2.2, 1.0, "Mutex 1", fill=GRAY3, fontsize=FS, fontweight="bold" + ) + draw_box( + ax, 5.3, 1.0, 2.2, 1.0, "Mutex 2", fill=GRAY3, fontsize=FS, fontweight="bold" + ) + + # holds arrows (down) + draw_arrow(ax, 1.6, 3.5, 1.6, 2.0, lw=2) + ax.text(0.9, 2.7, "trzyma", fontsize=FS_SMALL, rotation=90, va="center") + draw_arrow(ax, 6.4, 3.5, 6.4, 2.0, lw=2) + ax.text(7.0, 2.7, "trzyma", fontsize=FS_SMALL, rotation=90, va="center") + + # waits-for arrows (across, red) + draw_arrow(ax, 2.7, 4.3, 5.3, 4.3, lw=2.5, color="#C62828") + ax.text( + 4.0, + 4.7, + "czeka na Mutex 2", + fontsize=FS_SMALL, + ha="center", + fontweight="bold", + color="#C62828", + ) + draw_arrow(ax, 5.3, 3.7, 2.7, 3.7, lw=2.5, color="#C62828") + ax.text( + 4.0, + 3.1, + "czeka na Mutex 1", + fontsize=FS_SMALL, + ha="center", + fontweight="bold", + color="#C62828", + ) + + fig.tight_layout(rect=[0, 0, 1, 0.9]) + save_fig(fig, "q9_deadlock_scenario.png") + + +# ============================================================ +# 12. Coffman conditions + prevention strategies +# ============================================================ +def gen_coffman_strategies() -> None: + """Gen coffman strategies.""" + fig, ax = plt.subplots(figsize=(9, 4)) + ax.set_xlim(0, 11.5) + ax.set_ylim(-3.5, 1) + ax.set_aspect("auto") + ax.axis("off") + ax.set_title( + "Warunki Coffmana — zapobieganie deadlockowi", + fontsize=FS_TITLE, + fontweight="bold", + pad=10, + ) + + headers = ["Warunek", "Opis", "Jak złamać", "Przykład"] + col_w = [2.5, 2.5, 3.0, 3.0] + rows = [ + [ + "1. Mutual Exclusion", + "zasób wyłączny", + "współdzielony zasób", + "Read-write lock", + ], + [ + "2. Hold and Wait", + "trzymaj + czekaj", + "bierz WSZYSTKIE naraz", + "lock(m1,m2) atomowo", + ], + [ + "3. No Preemption", + "nie zabierzesz siłą", + "timeout / trylock", + "pthread_mutex_trylock()", + ], + [ + "4. Circular Wait", + "cykliczne oczekiw.", + "porządek liniowy", + "zawsze m1 przed m2", + ], + ] + draw_table( + ax, headers, rows, x0=0.25, y0=0.5, col_widths=col_w, row_h=0.6, fontsize=7 + ) + + ax.text( + 5.75, + -3.1, + "▸ Najczęstsza strategia: PORZĄDEK LINIOWY — " + "numeruj mutexy, zawsze blokuj rosnąco", + fontsize=FS, + ha="center", + fontweight="bold", + bbox={"boxstyle": "round,pad=0.2", "facecolor": GRAY4, "edgecolor": GRAY3}, + ) + + save_fig(fig, "q9_coffman_strategies.png") + + +# ============================================================ +# 13. Starvation + Priority Inversion (2-panel) +# ============================================================ +def gen_starvation_priority() -> None: + """Gen starvation priority.""" + fig, axes = plt.subplots(1, 2, figsize=(11, 4.5)) + fig.suptitle( + "Zagłodzenie i Inwersja priorytetów", fontsize=FS_TITLE, fontweight="bold" + ) + + # Panel 1: Starvation + aging + ax = axes[0] + ax.set_xlim(0, 8) + ax.set_ylim(0, 6) + ax.set_aspect("auto") + ax.axis("off") + ax.set_title("Zagłodzenie (Starvation)", fontsize=FS_LABEL, fontweight="bold") + + threads = [ + ("Wątek HIGH", "prio=10", GRAY5, 3.0), + ("Wątek HIGH", "prio=9", GRAY3, 2.2), + ("Wątek MED", "prio=5", GRAY2, 1.4), + ("Wątek LOW", "prio=1 → głoduje!", "#F8D7DA", 0.6), + ] + for name, prio, color, y in threads: + draw_box( + ax, 0.5, y, 2.0, 0.6, name, fill=color, fontsize=FS_SMALL, fontweight="bold" + ) + ax.text(2.8, y + 0.3, prio, fontsize=FS_SMALL, va="center") + + ax.text( + 1.5, + 4.2, + "CPU zawsze\ndostaje HIGH!", + fontsize=FS, + ha="center", + fontweight="bold", + ) + draw_arrow(ax, 1.5, 3.9, 1.5, 3.65, lw=1.5) + + # Aging solution + draw_box(ax, 4.5, 1.5, 3.2, 2.5, "", fill=GRAY4, rounded=True) + ax.text(6.1, 3.7, "Rozwiązanie: AGING", fontsize=FS, fontweight="bold", ha="center") + aging = [ + "t=0: prio=1", + "t=100ms: prio=2", + "t=200ms: prio=3", + "...", + "w końcu → CPU!", + ] + for i, line in enumerate(aging): + ax.text( + 6.1, 3.2 - i * 0.4, line, fontsize=FS_SMALL, ha="center", family="monospace" + ) + + # Panel 2: Priority Inversion + ax = axes[1] + ax.set_xlim(0, 10) + ax.set_ylim(0, 6) + ax.set_aspect("auto") + ax.axis("off") + ax.set_title("Inwersja priorytetów", fontsize=FS_LABEL, fontweight="bold") + + # Timeline + labels = ["H (wysoki)", "M (średni)", "L (niski)"] + ys = [4.2, 2.8, 1.4] + for label, y in zip(labels, ys, strict=False): + ax.text(0.3, y + 0.2, label, fontsize=FS, fontweight="bold", va="center") + + # L runs and locks mutex + draw_box(ax, 2.0, ys[2], 1.2, 0.5, "lock(m)", fill=GRAY1, fontsize=FS_SMALL) + + # M preempts L + draw_box(ax, 3.5, ys[1], 3.0, 0.5, "M pracuje...", fill=GRAY3, fontsize=FS_SMALL) + + # H waits for mutex + draw_box( + ax, + 3.5, + ys[0], + 3.0, + 0.5, + "CZEKA na mutex!", + fill="#F8D7DA", + fontsize=FS_SMALL, + fontweight="bold", + ) + + # M finishes, L continues, unlocks + draw_box(ax, 6.8, ys[2], 1.5, 0.5, "unlock(m)", fill=GRAY1, fontsize=FS_SMALL) + draw_box(ax, 8.5, ys[0], 1.2, 0.5, "H runs", fill=GRAY4, fontsize=FS_SMALL) + + # Explanation + ax.text( + 5.0, + 0.5, + "H czeka na M (mimo H > M)!\n" + "Rozwiązanie: Priority Inheritance\n" + "L dziedziczy priorytet H → M nie wypycha L", + fontsize=FS_SMALL, + ha="center", + style="italic", + bbox={"boxstyle": "round,pad=0.3", "facecolor": GRAY4, "edgecolor": GRAY3}, + ) + + ax.text( + 5.0, + 0.0, + "Mars Pathfinder (1997) — klasyczny bug!", + fontsize=FS_SMALL, + ha="center", + fontweight="bold", + ) + + fig.tight_layout(rect=[0, 0, 1, 0.9]) + save_fig(fig, "q9_starvation_priority.png") diff --git a/python_pkg/praca_magisterska_video/generate_images/_sched_common.py b/python_pkg/praca_magisterska_video/generate_images/_sched_common.py new file mode 100644 index 0000000..d6c0af9 --- /dev/null +++ b/python_pkg/praca_magisterska_video/generate_images/_sched_common.py @@ -0,0 +1,87 @@ +"""Common constants and utilities for scheduling diagrams.""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import TYPE_CHECKING + +import matplotlib.patches as mpatches +from matplotlib.patches import FancyBboxPatch + +if TYPE_CHECKING: + from matplotlib.axes import Axes + +_logger = logging.getLogger(__name__) + +DPI = 300 +BG = "white" +LN = "black" +FS = 8 +FS_TITLE = 11 +OUTPUT_DIR = str(Path(__file__).resolve().parent / "img") +Path(OUTPUT_DIR).mkdir(parents=True, exist_ok=True) + +GRAY1 = "#E8E8E8" +GRAY2 = "#D0D0D0" +GRAY3 = "#B8B8B8" +GRAY4 = "#F5F5F5" +GRAY5 = "#C0C0C0" + +MIN_COLUMN_INDEX = 3 +FONTWEIGHT_THRESHOLD = 3 + + +def draw_box( + ax: Axes, + x: float, + y: float, + w: float, + h: float, + text: str, + fill: str = "white", + lw: float = 1.2, + fontsize: float = FS, + fontweight: str = "normal", + ha: str = "center", + va: str = "center", + *, + rounded: bool = True, +) -> None: + """Draw box.""" + if rounded: + rect = FancyBboxPatch( + (x, y), w, h, boxstyle="round,pad=0.05", lw=lw, edgecolor=LN, facecolor=fill + ) + else: + rect = mpatches.Rectangle((x, y), w, h, lw=lw, edgecolor=LN, facecolor=fill) + ax.add_patch(rect) + ax.text( + x + w / 2, + y + h / 2, + text, + ha=ha, + va=va, + fontsize=fontsize, + fontweight=fontweight, + wrap=True, + ) + + +def draw_arrow( + ax: Axes, + x1: float, + y1: float, + x2: float, + y2: float, + lw: float = 1.2, + style: str = "->", + color: str = LN, +) -> None: + """Draw arrow.""" + ax.annotate( + "", + xy=(x2, y2), + xytext=(x1, y1), + arrowprops={"arrowstyle": style, "color": color, "lw": lw}, + ) diff --git a/python_pkg/praca_magisterska_video/generate_images/_sched_complexity_edd.py b/python_pkg/praca_magisterska_video/generate_images/_sched_complexity_edd.py new file mode 100644 index 0000000..ebd41ee --- /dev/null +++ b/python_pkg/praca_magisterska_video/generate_images/_sched_complexity_edd.py @@ -0,0 +1,309 @@ +"""Scheduling complexity landscape and EDD example diagrams.""" + +from __future__ import annotations + +import logging +from pathlib import Path + +import matplotlib.patches as mpatches +from matplotlib.patches import FancyBboxPatch +import matplotlib.pyplot as plt + +from python_pkg.praca_magisterska_video.generate_images._sched_common import ( + BG, + DPI, + FS_TITLE, + GRAY1, + GRAY2, + GRAY3, + GRAY4, + GRAY5, + LN, + OUTPUT_DIR, + draw_arrow, +) + +_logger = logging.getLogger(__name__) + + +# ============================================================ +# SCHEDULING COMPLEXITY LANDSCAPE +# ============================================================ +def draw_complexity_map() -> None: + """Draw complexity map.""" + _fig, ax = plt.subplots(1, 1, figsize=(8.27, 5)) + ax.set_xlim(0, 10) + ax.set_ylim(0, 7) + ax.set_aspect("equal") + ax.axis("off") + ax.set_title( + "Złożoność problemów szeregowania — od łatwych do NP-trudnych", + fontsize=FS_TITLE, + fontweight="bold", + pad=10, + ) + + # Gradient arrow at the top + ax.annotate( + "", + xy=(9.5, 6.2), + xytext=(0.5, 6.2), + arrowprops={"arrowstyle": "->", "color": LN, "lw": 2}, + ) + ax.text(5, 6.5, "Rosnąca złożoność", ha="center", fontsize=9, fontweight="bold") + + # Easy (polynomial) region + easy_rect = FancyBboxPatch( + (0.3, 2.8), + 4.0, + 3.0, + boxstyle="round,pad=0.15", + lw=1.5, + edgecolor="#666666", + facecolor=GRAY4, + linestyle="-", + ) + ax.add_patch(easy_rect) + ax.text( + 2.3, + 5.5, + "WIELOMIANOWE O(n log n)", + ha="center", + fontsize=9, + fontweight="bold", + color="#444444", + ) + + easy_problems = [ + ("1 || ΣCⱼ", "SPT", GRAY1, 4.8), + ("1 || Lmax", "EDD", GRAY2, 4.0), + ("F2 || Cmax", "Johnson", GRAY1, 3.2), + ] + for prob, method, fill, y in easy_problems: + rect = FancyBboxPatch( + (0.6, y), + 3.5, + 0.6, + boxstyle="round,pad=0.05", + lw=1, + edgecolor=LN, + facecolor=fill, + ) + ax.add_patch(rect) + ax.text( + 1.2, + y + 0.3, + prob, + ha="center", + va="center", + fontsize=8, + fontweight="bold", + fontfamily="monospace", + ) + ax.text(3.0, y + 0.3, f"→ {method}", ha="center", va="center", fontsize=8) + + # Hard (NP) region + hard_rect = FancyBboxPatch( + (5.3, 2.8), + 4.3, + 3.0, + boxstyle="round,pad=0.15", + lw=1.5, + edgecolor="#444444", + facecolor=GRAY3, + linestyle="-", + ) + ax.add_patch(hard_rect) + ax.text( + 7.45, + 5.5, + "NP-TRUDNE", + ha="center", + fontsize=9, + fontweight="bold", + color="#333333", + ) + + hard_problems = [ + ("Pm || Cmax\n(m≥2)", "LPT heuryst.", GRAY2, 4.5), + ("1 || ΣTⱼ", "branch&bound", GRAY4, 3.7), + ("Jm || Cmax\n(m≥3)", "metaheuryst.", GRAY5, 2.9), + ] + for prob, method, fill, y in hard_problems: + rect = FancyBboxPatch( + (5.6, y), + 3.7, + 0.7, + boxstyle="round,pad=0.05", + lw=1, + edgecolor=LN, + facecolor=fill, + ) + ax.add_patch(rect) + ax.text( + 6.5, + y + 0.35, + prob, + ha="center", + va="center", + fontsize=7, + fontweight="bold", + fontfamily="monospace", + ) + ax.text(8.2, y + 0.35, f"→ {method}", ha="center", va="center", fontsize=7) + + # Arrow connecting + draw_arrow(ax, 4.4, 4.0, 5.2, 4.0, lw=2, color="#888888") + ax.text(4.8, 4.25, "+1\nmaszyna", ha="center", fontsize=6, color="#888888") + + # Bottom: key insight + ax.text( + 5.0, + 1.8, + "„Dodanie jednej maszyny lub jednego ograniczenia\n" + 'może zmienić problem z łatwego na NP-trudny!"', + ha="center", + fontsize=8, + fontweight="bold", + style="italic", + bbox={ + "boxstyle": "round,pad=0.3", + "facecolor": GRAY4, + "edgecolor": GRAY3, + "lw": 1, + }, + ) + + # Bottom examples + ax.text( + 5.0, + 0.8, + "1 maszyna → łatwe (sortuj) | ≥2 maszyny równoległe → NP-trudne\n" + "Flow shop 2 maszyny → Johnson O(n log n) | Flow shop 3 maszyny → NP-trudne", + ha="center", + fontsize=7, + color="#555555", + ) + + plt.tight_layout() + plt.savefig( + str(Path(OUTPUT_DIR) / "scheduling_complexity_map.png"), + dpi=DPI, + bbox_inches="tight", + facecolor=BG, + ) + plt.close() + _logger.info(" ✓ scheduling_complexity_map.png") + + +# ============================================================ +# EDD EXAMPLE (1 || Lmax) +# ============================================================ +def draw_edd_example() -> None: + """Draw edd example.""" + _fig, ax = plt.subplots(1, 1, figsize=(8.27, 4)) + ax.set_xlim(-2, 28) + ax.set_ylim(-2, 4) + ax.axis("off") + ax.set_title( + "EDD (Earliest Due Date) — 1 || Lmax — Przykład", + fontsize=FS_TITLE, + fontweight="bold", + pad=8, + ) + + # Tasks: name, processing time, due date + tasks = [("J1", 4, 10), ("J2", 2, 6), ("J3", 6, 15), ("J4", 3, 8), ("J5", 5, 18)] + # EDD: sort by due date + edd_order = sorted(tasks, key=lambda x: x[2]) + + bar_y = 1.5 + bar_h = 0.8 + t = 0 + fills_edd = [GRAY1, GRAY2, GRAY4, GRAY3, GRAY5] + + lateness_vals = [] + for i, (name, p, d) in enumerate(edd_order): + rect = mpatches.Rectangle( + (t, bar_y), p, bar_h, lw=1.2, edgecolor=LN, facecolor=fills_edd[i] + ) + ax.add_patch(rect) + ax.text( + t + p / 2, + bar_y + bar_h / 2, + f"{name}\np={p}, d={d}", + ha="center", + va="center", + fontsize=6.5, + fontweight="bold", + ) + t += p + lateness = t - d + lateness_vals.append(lateness) + + # Due date marker + ax.plot( + [d, d], [bar_y - 0.4, bar_y - 0.1], color="#888888", lw=0.8, linestyle="--" + ) + ax.text( + d, + bar_y - 0.5, + f"d={d}", + ha="center", + va="top", + fontsize=5.5, + color="#888888", + ) + + # Completion + lateness + ax.plot([t, t], [bar_y + bar_h, bar_y + bar_h + 0.15], color=LN, lw=0.8) + ax.text( + t, + bar_y + bar_h + 0.2, + f"C={t}\nL={lateness}", + ha="center", + va="bottom", + fontsize=5.5, + ) + + # Time axis + ax.plot([0, 22], [bar_y - 0.05, bar_y - 0.05], color=LN, lw=0.5) + + lmax = max(lateness_vals) + ax.text( + 22, + bar_y + bar_h / 2, + f"Lmax = {lmax}", + ha="left", + va="center", + fontsize=10, + fontweight="bold", + bbox={"boxstyle": "round,pad=0.2", "facecolor": GRAY1, "edgecolor": LN}, + ) + + # Bottom mnemonic + ax.text( + 10, + -1.3, + '„Early Due Date Does it first" — najpilniejszy deadline idzie pierwszy', + ha="center", + fontsize=8, + fontweight="bold", + style="italic", + bbox={ + "boxstyle": "round,pad=0.3", + "facecolor": GRAY4, + "edgecolor": GRAY3, + "lw": 0.8, + }, + ) + + plt.tight_layout() + plt.savefig( + str(Path(OUTPUT_DIR) / "scheduling_edd_example.png"), + dpi=DPI, + bbox_inches="tight", + facecolor=BG, + ) + plt.close() + _logger.info(" ✓ scheduling_edd_example.png") diff --git a/python_pkg/praca_magisterska_video/generate_images/_sched_graham.py b/python_pkg/praca_magisterska_video/generate_images/_sched_graham.py new file mode 100644 index 0000000..9c0e2fc --- /dev/null +++ b/python_pkg/praca_magisterska_video/generate_images/_sched_graham.py @@ -0,0 +1,484 @@ +"""Graham notation α|β|γ visual mnemonic map diagram.""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import TYPE_CHECKING + +from matplotlib.patches import FancyBboxPatch +import matplotlib.pyplot as plt + +from python_pkg.praca_magisterska_video.generate_images._sched_common import ( + BG, + DPI, + FS_TITLE, + GRAY1, + GRAY2, + GRAY3, + GRAY4, + GRAY5, + LN, + OUTPUT_DIR, +) + +if TYPE_CHECKING: + from matplotlib.axes import Axes + +_logger = logging.getLogger(__name__) + + +def draw_graham_notation() -> None: + """Draw graham notation.""" + _fig, ax = plt.subplots(1, 1, figsize=(8.27, 10)) + ax.set_xlim(0, 10) + ax.set_ylim(0, 14) + ax.set_aspect("equal") + ax.axis("off") + ax.set_title( + "Notacja Grahama \u03b1 | β | \u03b3 — Mapa mnemoniczna", + fontsize=FS_TITLE + 1, + fontweight="bold", + pad=12, + ) + + _draw_graham_formula_bar(ax) + _draw_graham_alpha_beta(ax) + _draw_graham_lower(ax) + + plt.tight_layout() + plt.savefig( + str(Path(OUTPUT_DIR) / "scheduling_graham_notation.png"), + dpi=DPI, + bbox_inches="tight", + facecolor=BG, + ) + plt.close() + _logger.info(" ✓ scheduling_graham_notation.png") + + +def _draw_graham_formula_bar(ax: Axes) -> None: + """Draw the top alpha|beta|gamma formula bar.""" + bar_y = 12.5 + bar_h = 1.0 + # alpha box + rect = FancyBboxPatch( + (0.5, bar_y), + 2.5, + bar_h, + boxstyle="round,pad=0.08", + lw=2, + edgecolor=LN, + facecolor=GRAY1, + ) + ax.add_patch(rect) + ax.text( + 1.75, + bar_y + bar_h / 2, + "\u03b1", + fontsize=20, + fontweight="bold", + ha="center", + va="center", + ) + ax.text( + 1.75, + bar_y - 0.25, + "MASZYNY", + fontsize=8, + fontweight="bold", + ha="center", + va="top", + color="#444444", + ) + + # separator | + ax.text( + 3.3, + bar_y + bar_h / 2, + "|", + fontsize=24, + fontweight="bold", + ha="center", + va="center", + ) + + # β box + rect = FancyBboxPatch( + (3.7, bar_y), + 2.5, + bar_h, + boxstyle="round,pad=0.08", + lw=2, + edgecolor=LN, + facecolor=GRAY2, + ) + ax.add_patch(rect) + ax.text( + 4.95, + bar_y + bar_h / 2, + "β", + fontsize=20, + fontweight="bold", + ha="center", + va="center", + ) + ax.text( + 4.95, + bar_y - 0.25, + "OGRANICZENIA", + fontsize=8, + fontweight="bold", + ha="center", + va="top", + color="#444444", + ) + + # separator | + ax.text( + 6.5, + bar_y + bar_h / 2, + "|", + fontsize=24, + fontweight="bold", + ha="center", + va="center", + ) + + # gamma box + rect = FancyBboxPatch( + (6.9, bar_y), + 2.5, + bar_h, + boxstyle="round,pad=0.08", + lw=2, + edgecolor=LN, + facecolor=GRAY3, + ) + ax.add_patch(rect) + ax.text( + 8.15, + bar_y + bar_h / 2, + "\u03b3", + fontsize=20, + fontweight="bold", + ha="center", + va="center", + ) + ax.text( + 8.15, + bar_y - 0.25, + "CEL", + fontsize=8, + fontweight="bold", + ha="center", + va="top", + color="#444444", + ) + + +def _draw_graham_alpha_beta(ax: Axes) -> None: + """Draw alpha (machines) and beta (constraints) sections.""" + start_x = 0.3 + col_w = 1.28 + + # === SECTION alpha: MACHINES === + sec_y = 11.5 + ax.text( + 0.3, + sec_y, + '\u03b1 — „1 Prawdziwy Quasi-Rycerz Forsuje Jaskinię Orków"', + fontsize=8, + fontweight="bold", + va="top", + style="italic", + color="#333333", + ) + + alpha_items = [ + ("1", "jedna maszyna", "●", GRAY4), + ("Pm", "identyczne Parallel", "●●●", GRAY1), + ("Qm", "Quasi-uniform\n(różne prędkości)", "●●◐", GRAY4), + ("Rm", "Random unrelated\n(czasy per para)", "●◆▲", GRAY1), + ("Fm", "Flow shop\n(ta sama kolejność)", "→→→", GRAY2), + ("Jm", "Job shop\n(indyw. trasy)", "↗↙↘", GRAY4), + ("Om", "Open shop\n(dowolna kolej.)", "?→?", GRAY1), + ] + + col_w = 1.28 + box_h_a = 1.1 + start_x = 0.3 + start_y = 9.6 + + for i, (symbol, desc, icon, fill) in enumerate(alpha_items): + x = start_x + i * col_w + y = start_y + rect = FancyBboxPatch( + (x, y), + col_w - 0.1, + box_h_a, + boxstyle="round,pad=0.04", + lw=1, + edgecolor=LN, + facecolor=fill, + ) + ax.add_patch(rect) + ax.text( + x + (col_w - 0.1) / 2, + y + box_h_a - 0.15, + symbol, + ha="center", + va="top", + fontsize=9, + fontweight="bold", + ) + ax.text( + x + (col_w - 0.1) / 2, + y + box_h_a / 2 - 0.1, + desc, + ha="center", + va="center", + fontsize=5.5, + ) + ax.text( + x + (col_w - 0.1) / 2, y + 0.12, icon, ha="center", va="bottom", fontsize=7 + ) + + # Complexity arrow under alpha + arr_y = 9.35 + ax.annotate( + "", + xy=(9.0, arr_y), + xytext=(0.5, arr_y), + arrowprops={"arrowstyle": "->", "color": "#666666", "lw": 1.5}, + ) + ax.text( + 4.8, + arr_y - 0.18, + "rosnąca złożoność →", + ha="center", + fontsize=6, + color="#666666", + ) + + # === SECTION β: CONSTRAINTS === + sec_y2 = 8.9 + ax.text( + 0.3, + sec_y2, + "β — „Robak Daje Deadline: Przerwy Poprzedzają Pojedyncze Setup'y\"", + fontsize=8, + fontweight="bold", + va="top", + style="italic", + color="#333333", + ) + + beta_items = [ + ("rⱼ", "release\ndates", "Robak\ndostępne\nod czasu rⱼ", GRAY1), + ("dⱼ", "due\ndates", "Daje\ntermin soft\n(kara za spóźn.)", GRAY4), + ("d̄ⱼ", "dead-\nlines", "Deadline\ntermin hard\n(musi dotrzymać)", GRAY1), + ("pmtn", "preemp-\ntion", "Przerwy\nmożna\nprzerwać", GRAY2), + ("prec", "prece-\ndencje", "Poprzedzają\nA->B (DAG)", GRAY4), + ("pⱼ=1", "unit\ntime", "Pojedyncze\nwszystkie = 1", GRAY1), + ("sⱼₖ", "setup\ntimes", "Setup'y\nprzezbrojenie\nmiędzy j->k", GRAY4), + ] + + start_y2 = 7.0 + box_h_b = 1.4 + + for i, (symbol, _label, desc, fill) in enumerate(beta_items): + x = start_x + i * col_w + y = start_y2 + rect = FancyBboxPatch( + (x, y), + col_w - 0.1, + box_h_b, + boxstyle="round,pad=0.04", + lw=1, + edgecolor=LN, + facecolor=fill, + ) + ax.add_patch(rect) + ax.text( + x + (col_w - 0.1) / 2, + y + box_h_b - 0.12, + symbol, + ha="center", + va="top", + fontsize=9, + fontweight="bold", + ) + ax.text( + x + (col_w - 0.1) / 2, + y + box_h_b / 2 - 0.05, + desc, + ha="center", + va="center", + fontsize=5, + ) + + +def _draw_graham_lower(ax: Axes) -> None: + """Draw gamma criteria, examples, and footer sections.""" + start_x = 0.3 + + # === SECTION gamma: CRITERIA === + sec_y3 = 6.5 + ax.text( + 0.3, + sec_y3, + '\u03b3 — „Ciężki Sum Ważony Lata, Tardiness Uderza"', + fontsize=8, + fontweight="bold", + va="top", + style="italic", + color="#333333", + ) + + gamma_items = [ + ("Cmax", "makespan\nmax(Cⱼ)", "Jak długo\ntrwa WSZYSTKO?", GRAY2), + ("ΣCⱼ", "suma\nukończeń", "Średni czas\noczekiwania?", GRAY4), + ("ΣwⱼCⱼ", "ważona\nsuma", "Priorytety\nzadań?", GRAY1), + ("Lmax", "max\nopóźnienie", "Najgorsze\nspóźnienie?", GRAY2), + ("ΣTⱼ", "suma\nspóźnień", "Łączne\nspóźnienia?", GRAY4), + ("ΣUⱼ", "liczba\nspóźnionych", "Ile spóźnionych\nzadań?", GRAY1), + ] + + start_y3 = 4.5 + box_h_g = 1.4 + col_w_g = 1.5 + + for i, (symbol, label, question, fill) in enumerate(gamma_items): + x = start_x + i * col_w_g + y = start_y3 + rect = FancyBboxPatch( + (x, y), + col_w_g - 0.1, + box_h_g, + boxstyle="round,pad=0.04", + lw=1, + edgecolor=LN, + facecolor=fill, + ) + ax.add_patch(rect) + ax.text( + x + (col_w_g - 0.1) / 2, + y + box_h_g - 0.1, + symbol, + ha="center", + va="top", + fontsize=9, + fontweight="bold", + ) + ax.text( + x + (col_w_g - 0.1) / 2, + y + box_h_g / 2 - 0.05, + label, + ha="center", + va="center", + fontsize=6, + ) + ax.text( + x + (col_w_g - 0.1) / 2, + y + 0.15, + f'„{question}"', + ha="center", + va="bottom", + fontsize=5, + style="italic", + ) + + # === BOTTOM: Example + Optimal methods === + ex_y = 3.5 + ax.text( + 0.3, + ex_y, + "Przykłady zapisu i optymalne metody:", + fontsize=8, + fontweight="bold", + va="top", + ) + + examples = [ + ("1 || ΣCⱼ", "SPT (najkrótsze\nnajpierw)", "O(n log n)", GRAY1), + ("1 || Lmax", "EDD (najwcześniejszy\ntermin)", "O(n log n)", GRAY4), + ("F2 || Cmax", "Algorytm\nJohnsona", "O(n log n)", GRAY2), + ("Pm || Cmax", "LPT heurystyka\n(NP-trudny!)", "NP-hard", GRAY3), + ("Jm || Cmax", "Branch & Bound\n(NP-trudny!)", "NP-hard", GRAY5), + ] + + ex_start_y = 1.8 + ex_box_w = 1.72 + ex_box_h = 1.4 + + for i, (notation, method, complexity, fill) in enumerate(examples): + x = start_x + i * (ex_box_w + 0.1) + y = ex_start_y + rect = FancyBboxPatch( + (x, y), + ex_box_w, + ex_box_h, + boxstyle="round,pad=0.04", + lw=1.2, + edgecolor=LN, + facecolor=fill, + ) + ax.add_patch(rect) + ax.text( + x + ex_box_w / 2, + y + ex_box_h - 0.12, + notation, + ha="center", + va="top", + fontsize=8, + fontweight="bold", + fontfamily="monospace", + ) + ax.text( + x + ex_box_w / 2, + y + ex_box_h / 2 - 0.05, + method, + ha="center", + va="center", + fontsize=6, + ) + ax.text( + x + ex_box_w / 2, + y + 0.12, + complexity, + ha="center", + va="bottom", + fontsize=6.5, + fontweight="bold", + color="#555555", + ) + + # Footer mnemonic summary + ax.text( + 5.0, + 0.8, + '„\u03b1|β|\u03b3 = Maszyny | Ograniczenia | Cel"', + ha="center", + fontsize=9, + fontweight="bold", + style="italic", + color="#333333", + bbox={ + "boxstyle": "round,pad=0.3", + "facecolor": GRAY4, + "edgecolor": GRAY3, + "lw": 1, + }, + ) + + ax.text( + 5.0, + 0.2, + "\u03b1: ILE maszyn i JAKIE? " + "β: JAKIE ograniczenia zadań? " + "\u03b3: CO minimalizujemy?", + ha="center", + fontsize=7, + color="#555555", + ) diff --git a/python_pkg/praca_magisterska_video/generate_images/_sched_johnson.py b/python_pkg/praca_magisterska_video/generate_images/_sched_johnson.py new file mode 100644 index 0000000..39cd829 --- /dev/null +++ b/python_pkg/praca_magisterska_video/generate_images/_sched_johnson.py @@ -0,0 +1,318 @@ +"""Johnson's algorithm Gantt chart diagram (F2||Cmax).""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import TYPE_CHECKING + +import matplotlib.patches as mpatches +import matplotlib.pyplot as plt + +from python_pkg.praca_magisterska_video.generate_images._sched_common import ( + BG, + DPI, + FONTWEIGHT_THRESHOLD, + FS_TITLE, + GRAY1, + GRAY2, + GRAY3, + GRAY4, + GRAY5, + LN, + MIN_COLUMN_INDEX, + OUTPUT_DIR, +) + +if TYPE_CHECKING: + from matplotlib.axes import Axes + +_logger = logging.getLogger(__name__) + + +def draw_johnson_gantt() -> None: + """Draw johnson gantt.""" + _fig, axes = plt.subplots( + 2, 1, figsize=(8.27, 7), gridspec_kw={"height_ratios": [1, 1.8]} + ) + + _draw_johnson_decision_table(axes[0]) + _draw_johnson_gantt_chart(axes[1]) + + plt.tight_layout() + plt.savefig( + str(Path(OUTPUT_DIR) / "scheduling_johnson_gantt.png"), + dpi=DPI, + bbox_inches="tight", + facecolor=BG, + ) + plt.close() + _logger.info(" ✓ scheduling_johnson_gantt.png") + + +def _draw_johnson_decision_table(ax: Axes) -> None: + """Draw the Johnson algorithm decision table.""" + ax.set_xlim(0, 10) + ax.set_ylim(0, 5) + ax.set_aspect("equal") + ax.axis("off") + ax.set_title( + "Algorytm Johnsona (F2 || Cmax) — Decyzja + Diagram Gantta", + fontsize=FS_TITLE, + fontweight="bold", + pad=10, + ) + + # Task table + tasks = ["J1", "J2", "J3", "J4", "J5"] + a_times = [4, 2, 6, 1, 3] + b_times = [5, 3, 2, 7, 4] + min_vals = [min(a, b) for a, b in zip(a_times, b_times, strict=False)] + min_on = ["M1" if a <= b else "M2" for a, b in zip(a_times, b_times, strict=False)] + assign = ["POCZątek" if m == "M1" else "KONIEC" for m in min_on] + + # Draw table + col_w_t = 1.3 + row_h = 0.55 + headers = ["Zadanie", "aⱼ (M1)", "bⱼ (M2)", "min", "min na", "Przydziel"] + table_x = 0.8 + table_y = 3.8 + + for j, hdr in enumerate(headers): + x = table_x + j * col_w_t + rect = mpatches.Rectangle( + (x, table_y), col_w_t, row_h, lw=1, edgecolor=LN, facecolor=GRAY2 + ) + ax.add_patch(rect) + ax.text( + x + col_w_t / 2, + table_y + row_h / 2, + hdr, + ha="center", + va="center", + fontsize=6.5, + fontweight="bold", + ) + + for i in range(5): + row_data = [ + tasks[i], + str(a_times[i]), + str(b_times[i]), + str(min_vals[i]), + min_on[i], + assign[i], + ] + for j, val in enumerate(row_data): + x = table_x + j * col_w_t + y = table_y - (i + 1) * row_h + fill_c = GRAY1 if min_on[i] == "M1" else GRAY4 + if j == MIN_COLUMN_INDEX: # min column - highlight + fill_c = GRAY3 + rect = mpatches.Rectangle( + (x, y), col_w_t, row_h, lw=0.8, edgecolor=LN, facecolor=fill_c + ) + ax.add_patch(rect) + fw = "bold" if j >= FONTWEIGHT_THRESHOLD else "normal" + ax.text( + x + col_w_t / 2, + y + row_h / 2, + val, + ha="center", + va="center", + fontsize=6.5, + fontweight=fw, + ) + + # Sorting result + result_y = 0.7 + ax.text( + 5.0, + result_y + 0.4, + "Sortuj → POCZĄTEK ↑aⱼ: J4(1), J2(2), J5(3), J1(4) | KONIEC ↓bⱼ: J3(2)", + ha="center", + fontsize=7, + color="#333333", + ) + ax.text( + 5.0, + result_y, + "Optymalna kolejność: J4 → J2 → J5 → J1 → J3", + ha="center", + fontsize=9, + fontweight="bold", + bbox={ + "boxstyle": "round,pad=0.2", + "facecolor": GRAY1, + "edgecolor": LN, + "lw": 1.2, + }, + ) + + +def _draw_johnson_gantt_chart(ax2: Axes) -> None: + """Draw the Johnson algorithm Gantt chart.""" + ax2.set_xlim(-1, 24) + ax2.set_ylim(-1, 4) + ax2.axis("off") + + # Machines labels + m1_y = 2.5 + m2_y = 0.8 + bar_h = 0.9 + + ax2.text( + -0.8, + m1_y + bar_h / 2, + "M1", + ha="center", + va="center", + fontsize=11, + fontweight="bold", + ) + ax2.text( + -0.8, + m2_y + bar_h / 2, + "M2", + ha="center", + va="center", + fontsize=11, + fontweight="bold", + ) + + # Schedule: J4 → J2 → J5 → J1 → J3 + order = ["J4", "J2", "J5", "J1", "J3"] + a_ord = [1, 2, 3, 4, 6] # M1 times in order + b_ord = [7, 3, 4, 5, 2] # M2 times in order + fills = [GRAY1, GRAY2, GRAY4, GRAY3, GRAY5] + hatches = ["", "///", "", "\\\\\\", "xxx"] + + # M1 schedule + m1_starts = [] + t = 0 + for a in a_ord: + m1_starts.append(t) + t += a + m1_ends = [s + a for s, a in zip(m1_starts, a_ord, strict=False)] + + # M2 schedule (must wait for M1 finish AND previous M2 finish) + m2_starts = [] + m2_ends = [] + prev_m2_end = 0 + for i, b in enumerate(b_ord): + start = max(m1_ends[i], prev_m2_end) + m2_starts.append(start) + m2_ends.append(start + b) + prev_m2_end = start + b + + # Draw M1 bars + for i in range(5): + rect = mpatches.Rectangle( + (m1_starts[i], m1_y), + a_ord[i], + bar_h, + lw=1.2, + edgecolor=LN, + facecolor=fills[i], + hatch=hatches[i], + ) + ax2.add_patch(rect) + ax2.text( + m1_starts[i] + a_ord[i] / 2, + m1_y + bar_h / 2, + f"{order[i]}\n({a_ord[i]})", + ha="center", + va="center", + fontsize=7, + fontweight="bold", + ) + + # Draw M2 bars + for i in range(5): + rect = mpatches.Rectangle( + (m2_starts[i], m2_y), + b_ord[i], + bar_h, + lw=1.2, + edgecolor=LN, + facecolor=fills[i], + hatch=hatches[i], + ) + ax2.add_patch(rect) + ax2.text( + m2_starts[i] + b_ord[i] / 2, + m2_y + bar_h / 2, + f"{order[i]}\n({b_ord[i]})", + ha="center", + va="center", + fontsize=7, + fontweight="bold", + ) + + # Draw idle regions on M2 + idle_starts = [0] + idle_ends = [m2_starts[0]] + for i in range(1, 5): + if m2_starts[i] > m2_ends[i - 1]: + idle_starts.append(m2_ends[i - 1]) + idle_ends.append(m2_starts[i]) + + for s, e in zip(idle_starts, idle_ends, strict=False): + if e > s: + rect = mpatches.Rectangle( + (s, m2_y), + e - s, + bar_h, + lw=0.5, + edgecolor="#AAAAAA", + facecolor="white", + linestyle="--", + ) + ax2.add_patch(rect) + ax2.text( + s + (e - s) / 2, + m2_y + bar_h / 2, + "idle", + ha="center", + va="center", + fontsize=5, + color="#999999", + ) + + # Time axis + ax_y = m2_y - 0.15 + ax2.plot([0, 23], [ax_y, ax_y], color=LN, lw=0.8) + for t in range(0, 24, 2): + ax2.plot([t, t], [ax_y - 0.08, ax_y + 0.08], color=LN, lw=0.8) + ax2.text(t, ax_y - 0.25, str(t), ha="center", va="top", fontsize=6) + ax2.text(11.5, ax_y - 0.55, "czas", ha="center", fontsize=7) + + # Cmax annotation + ax2.annotate( + f"Cmax = {m2_ends[-1]}", + xy=(m2_ends[-1], m2_y + bar_h), + xytext=(m2_ends[-1] + 0.5, m2_y + bar_h + 0.6), + fontsize=10, + fontweight="bold", + color="#333333", + arrowprops={"arrowstyle": "->", "color": "#333333", "lw": 1.5}, + ) + + # Mnemonic at bottom + ax2.text( + 11, + -0.7, + "„Krótki na M1 → START (szybko karmi M2)" + " Krótki na M2 → KONIEC" + ' (szybko kończy)"', + ha="center", + fontsize=7.5, + fontweight="bold", + style="italic", + bbox={ + "boxstyle": "round,pad=0.3", + "facecolor": GRAY4, + "edgecolor": GRAY3, + "lw": 0.8, + }, + ) diff --git a/python_pkg/praca_magisterska_video/generate_images/_sched_spt_flow_job.py b/python_pkg/praca_magisterska_video/generate_images/_sched_spt_flow_job.py new file mode 100644 index 0000000..75c6706 --- /dev/null +++ b/python_pkg/praca_magisterska_video/generate_images/_sched_spt_flow_job.py @@ -0,0 +1,352 @@ +"""SPT vs LPT comparison and Flow Shop vs Job Shop diagrams.""" + +from __future__ import annotations + +import logging +from pathlib import Path +from typing import TYPE_CHECKING + +import matplotlib.patches as mpatches +import matplotlib.pyplot as plt + +from python_pkg.praca_magisterska_video.generate_images._sched_common import ( + BG, + DPI, + GRAY1, + GRAY2, + GRAY3, + GRAY4, + GRAY5, + LN, + OUTPUT_DIR, + draw_arrow, +) + +if TYPE_CHECKING: + from matplotlib.axes import Axes + +_logger = logging.getLogger(__name__) + + +# ============================================================ +# SPT vs LPT COMPARISON (1 || ΣCⱼ) +# ============================================================ +def draw_spt_comparison() -> None: + """Draw spt comparison.""" + fig, axes = plt.subplots(2, 1, figsize=(8.27, 5.5)) + + tasks_orig = [("J1", 5), ("J2", 3), ("J3", 8), ("J4", 2), ("J5", 6)] + + spt_order = sorted(tasks_orig, key=lambda x: x[1]) + lpt_order = sorted(tasks_orig, key=lambda x: -x[1]) + + fills_map = {"J1": GRAY1, "J2": GRAY2, "J3": GRAY3, "J4": GRAY4, "J5": GRAY5} + hatch_map = {"J1": "", "J2": "///", "J3": "xxx", "J4": "", "J5": "\\\\\\"} + + for _idx, (ax, order_list, title, is_optimal) in enumerate( + [ + (axes[0], spt_order, "SPT (Shortest Processing Time) — OPTYMALNE", True), + (axes[1], lpt_order, "LPT (Longest Processing Time) — gorsze!", False), + ] + ): + ax.set_xlim(-2, 26) + ax.set_ylim(-0.5, 2.5) + ax.axis("off") + color = "#222222" if is_optimal else "#666666" + marker = "✓" if is_optimal else "✗" + ax.set_title( + f"{marker} {title}", + fontsize=9, + fontweight="bold", + loc="left", + color=color, + pad=5, + ) + + bar_y = 1.0 + bar_h = 0.8 + t = 0 + completions = [] + + for name, duration in order_list: + rect = mpatches.Rectangle( + (t, bar_y), + duration, + bar_h, + lw=1.2, + edgecolor=LN, + facecolor=fills_map[name], + hatch=hatch_map[name], + ) + ax.add_patch(rect) + ax.text( + t + duration / 2, + bar_y + bar_h / 2, + f"{name}\n({duration})", + ha="center", + va="center", + fontsize=7, + fontweight="bold", + ) + t += duration + completions.append(t) + + # Completion time marker + ax.plot([t, t], [bar_y - 0.15, bar_y], color=LN, lw=0.8) + ax.text( + t, + bar_y - 0.25, + f"C={t}", + ha="center", + va="top", + fontsize=6, + color="#555555", + ) + + total = sum(completions) + # Time axis + ax.plot([0, 25], [bar_y - 0.05, bar_y - 0.05], color=LN, lw=0.5) + + # Sum annotation + comp_str = " + ".join(str(c) for c in completions) + ax.text( + 25, + bar_y + bar_h / 2, + f"ΣCⱼ = {comp_str}\n = {total}", + ha="left", + va="center", + fontsize=7, + fontweight="bold" if is_optimal else "normal", + color=color, + bbox={ + "boxstyle": "round,pad=0.2", + "facecolor": GRAY1 if is_optimal else "white", + "edgecolor": color, + "lw": 1, + }, + ) + + # Bottom annotation + fig.text( + 0.5, + 0.02, + '„Short People To the front"' + " — krótkie najpierw," + " jak niskie osoby w zdjęciu klasowym", + ha="center", + fontsize=8, + fontweight="bold", + style="italic", + color="#444444", + ) + + plt.tight_layout(rect=[0, 0.05, 1, 1]) + plt.savefig( + str(Path(OUTPUT_DIR) / "scheduling_spt_comparison.png"), + dpi=DPI, + bbox_inches="tight", + facecolor=BG, + ) + plt.close() + _logger.info(" ✓ scheduling_spt_comparison.png") + + +# ============================================================ +# FLOW SHOP vs JOB SHOP +# ============================================================ +def draw_flow_vs_job() -> None: + """Draw flow vs job.""" + _fig, axes = plt.subplots(1, 2, figsize=(8.27, 4.5)) + + _draw_flow_shop(axes[0]) + _draw_job_shop(axes[1]) + + plt.tight_layout() + plt.savefig( + str(Path(OUTPUT_DIR) / "scheduling_flow_vs_job.png"), + dpi=DPI, + bbox_inches="tight", + facecolor=BG, + ) + plt.close() + _logger.info(" ✓ scheduling_flow_vs_job.png") + + +def _draw_flow_shop(ax: Axes) -> None: + """Draw the Flow Shop diagram.""" + ax.set_xlim(0, 6) + ax.set_ylim(0, 6) + ax.set_aspect("equal") + ax.axis("off") + ax.set_title("Flow Shop (Fm)", fontsize=10, fontweight="bold", pad=8) + + # Machines in a row + machines_x = [1, 3, 5] + machines_y = 3 + mach_r = 0.4 + + for i, mx in enumerate(machines_x): + circle = plt.Circle( + (mx, machines_y), mach_r, facecolor=GRAY2, edgecolor=LN, lw=1.5 + ) + ax.add_patch(circle) + ax.text( + mx, + machines_y, + f"M{i + 1}", + ha="center", + va="center", + fontsize=9, + fontweight="bold", + ) + + # Arrows between machines + for i in range(len(machines_x) - 1): + draw_arrow( + ax, + machines_x[i] + mach_r + 0.05, + machines_y, + machines_x[i + 1] - mach_r - 0.05, + machines_y, + lw=2, + ) + + # Jobs all flowing the same way + jobs_flow = ["J1", "J2", "J3"] + for _j, (job, y_off) in enumerate(zip(jobs_flow, [0.8, 0, -0.8], strict=False)): + ax.text( + 0.2, + machines_y + y_off, + job, + ha="center", + va="center", + fontsize=7, + fontweight="bold", + bbox={"boxstyle": "round,pad=0.15", "facecolor": GRAY1, "edgecolor": LN}, + ) + # Dashed flow line + ax.annotate( + "", + xy=(5.5, machines_y + y_off * 0.3), + xytext=(0.5, machines_y + y_off), + arrowprops={ + "arrowstyle": "->", + "color": "#888888", + "lw": 0.8, + "linestyle": "dashed", + }, + ) + + ax.text( + 3, + 1.2, + "Wszystkie zadania:\nM1 → M2 → M3", + ha="center", + va="center", + fontsize=8, + bbox={"boxstyle": "round,pad=0.3", "facecolor": GRAY4, "edgecolor": GRAY3}, + ) + + ax.text( + 3, + 0.4, + "Jak taśma montażowa", + ha="center", + fontsize=7, + style="italic", + color="#666666", + ) + + +def _draw_job_shop(ax: Axes) -> None: + """Draw the Job Shop diagram.""" + mach_r = 0.4 + ax.set_xlim(0, 6) + ax.set_ylim(0, 6) + ax.set_aspect("equal") + ax.axis("off") + ax.set_title("Job Shop (Jm)", fontsize=10, fontweight="bold", pad=8) + + # Machines scattered + m_positions = [(1.5, 4.2), (4.5, 4.2), (3, 2.5)] + + for i, (mx, my) in enumerate(m_positions): + circle = plt.Circle((mx, my), mach_r, facecolor=GRAY2, edgecolor=LN, lw=1.5) + ax.add_patch(circle) + ax.text( + mx, my, f"M{i + 1}", ha="center", va="center", fontsize=9, fontweight="bold" + ) + + # J1: M1 → M2 → M3 (solid) + route1 = [(1.5, 4.2), (4.5, 4.2), (3, 2.5)] + for i in range(len(route1) - 1): + x1, y1 = route1[i] + x2, y2 = route1[i + 1] + dx = x2 - x1 + dy = y2 - y1 + d = (dx**2 + dy**2) ** 0.5 + draw_arrow( + ax, + x1 + mach_r * dx / d + 0.05, + y1 + mach_r * dy / d, + x2 - mach_r * dx / d - 0.05, + y2 - mach_r * dy / d, + lw=1.5, + ) + ax.text( + 0.3, + 4.8, + "J1: M1→M2→M3", + fontsize=7, + fontweight="bold", + bbox={"boxstyle": "round,pad=0.1", "facecolor": GRAY1, "edgecolor": LN}, + ) + + # J2: M2 → M3 → M1 (dashed) + route2 = [(4.5, 4.2), (3, 2.5), (1.5, 4.2)] + for i in range(len(route2) - 1): + x1, y1 = route2[i] + x2, y2 = route2[i + 1] + dx = x2 - x1 + dy = y2 - y1 + d = (dx**2 + dy**2) ** 0.5 + off = 0.15 # offset to avoid overlap + ax.annotate( + "", + xy=(x2 - mach_r * dx / d - 0.05, y2 - mach_r * dy / d + off), + xytext=(x1 + mach_r * dx / d + 0.05, y1 + mach_r * dy / d + off), + arrowprops={ + "arrowstyle": "->", + "color": "#555555", + "lw": 1.5, + "linestyle": "dashed", + }, + ) + ax.text( + 3.8, + 5.2, + "J2: M2→M3→M1", + fontsize=7, + fontweight="bold", + bbox={"boxstyle": "round,pad=0.1", "facecolor": GRAY4, "edgecolor": LN}, + ) + + ax.text( + 3, + 1.2, + "Każde zadanie:\nwłasna trasa!", + ha="center", + va="center", + fontsize=8, + bbox={"boxstyle": "round,pad=0.3", "facecolor": GRAY4, "edgecolor": GRAY3}, + ) + + ax.text( + 3, + 0.4, + "NP-trudny już dla 3 maszyn", + ha="center", + fontsize=7, + style="italic", + color="#666666", + ) diff --git a/python_pkg/praca_magisterska_video/generate_images/generate_pubsub_diagrams.py b/python_pkg/praca_magisterska_video/generate_images/generate_pubsub_diagrams.py index e7c80e4..7134b40 100755 --- a/python_pkg/praca_magisterska_video/generate_images/generate_pubsub_diagrams.py +++ b/python_pkg/praca_magisterska_video/generate_images/generate_pubsub_diagrams.py @@ -17,1208 +17,29 @@ One diagram per image -- no cramming. from __future__ import annotations -from dataclasses import dataclass import logging -from pathlib import Path -from typing import TYPE_CHECKING -import matplotlib as mpl - -mpl.use("Agg") - -import matplotlib.patches as mpatches -from matplotlib.patches import FancyBboxPatch -import matplotlib.pyplot as plt - -if TYPE_CHECKING: - from matplotlib.axes import Axes +from _pubsub_qos import ( + draw_qos_at_least_once, + draw_qos_at_most_once, + draw_qos_exactly_once, +) +from _pubsub_topic_content import ( + draw_sub_content, + draw_sub_topic, +) +from _pubsub_type_hierarchical import ( + draw_sub_hierarchical, + draw_sub_type, +) logger = logging.getLogger(__name__) -DPI = 300 -BG = "white" -LN = "black" -FS = 9 -FS_TITLE = 13 -FIG_W = 8.27 # A4 width in inches -OUTPUT_DIR = str(Path(__file__).resolve().parent / "img") -Path(OUTPUT_DIR).mkdir(parents=True, exist_ok=True) - -GRAY1 = "#E8E8E8" -GRAY2 = "#D0D0D0" -GRAY3 = "#B8B8B8" -GRAY4 = "#F5F5F5" -GRAY5 = "#C0C0C0" - - -@dataclass(frozen=True) -class BoxStyle: - """Optional styling for boxes.""" - - fill: str = "white" - lw: float = 1.2 - fontsize: float = FS - fontweight: str = "normal" - ha: str = "center" - va: str = "center" - rounded: bool = True - - -@dataclass(frozen=True) -class ArrowCfg: - """Config for arrows.""" - - lw: float = 1.2 - style: str = "->" - color: str = LN - label: str = "" - label_offset: float = 0.15 - label_fs: float = 8 - - -@dataclass(frozen=True) -class DashedCfg: - """Config for dashed arrows.""" - - lw: float = 1.0 - color: str = LN - label: str = "" - label_offset: float = 0.15 - label_fs: float = 8 - - -def draw_box( - ax: Axes, - pos: tuple[float, float], - size: tuple[float, float], - text: str, - style: BoxStyle | None = None, -) -> None: - """Draw box.""" - s = style or BoxStyle() - x, y = pos - w, h = size - if s.rounded: - rect = FancyBboxPatch( - (x, y), - w, - h, - boxstyle="round,pad=0.05", - lw=s.lw, - edgecolor=LN, - facecolor=s.fill, - ) - else: - rect = mpatches.Rectangle( - (x, y), - w, - h, - lw=s.lw, - edgecolor=LN, - facecolor=s.fill, - ) - ax.add_patch(rect) - ax.text( - x + w / 2, - y + h / 2, - text, - ha=s.ha, - va=s.va, - fontsize=s.fontsize, - fontweight=s.fontweight, - wrap=True, - ) - - -def draw_arrow( - ax: Axes, - start: tuple[float, float], - end: tuple[float, float], - cfg: ArrowCfg | None = None, -) -> None: - """Draw arrow.""" - c = cfg or ArrowCfg() - ax.annotate( - "", - xy=end, - xytext=start, - arrowprops={ - "arrowstyle": c.style, - "color": c.color, - "lw": c.lw, - }, - ) - if c.label: - mx = (start[0] + end[0]) / 2 - my = (start[1] + end[1]) / 2 + c.label_offset - ax.text( - mx, - my, - c.label, - ha="center", - va="bottom", - fontsize=c.label_fs, - color=c.color, - ) - - -def draw_dashed_arrow( - ax: Axes, - start: tuple[float, float], - end: tuple[float, float], - cfg: DashedCfg | None = None, -) -> None: - """Draw dashed arrow.""" - c = cfg or DashedCfg() - ax.annotate( - "", - xy=end, - xytext=start, - arrowprops={ - "arrowstyle": "->", - "color": c.color, - "lw": c.lw, - "linestyle": "dashed", - }, - ) - if c.label: - mx = (start[0] + end[0]) / 2 - my = (start[1] + end[1]) / 2 + c.label_offset - ax.text( - mx, - my, - c.label, - ha="center", - va="bottom", - fontsize=c.label_fs, - color=c.color, - ) - - -def draw_cross( - ax: Axes, - pos: tuple[float, float], - size: float = 0.15, - lw: float = 2.5, - color: str = "black", -) -> None: - """Draw cross.""" - x, y = pos - ax.plot( - [x - size, x + size], - [y - size, y + size], - color=color, - lw=lw, - ) - ax.plot( - [x - size, x + size], - [y + size, y - size], - color=color, - lw=lw, - ) - - -def draw_check( - ax: Axes, - pos: tuple[float, float], - size: float = 0.15, - lw: float = 2.5, - color: str = "black", -) -> None: - """Draw check.""" - x, y = pos - ax.plot( - [x - size, x - size * 0.2], - [y, y - size * 0.7], - color=color, - lw=lw, - ) - ax.plot( - [x - size * 0.2, x + size], - [y - size * 0.7, y + size * 0.5], - color=color, - lw=lw, - ) - - -def save(fig: plt.Figure, name: str) -> None: - """Save.""" - plt.tight_layout() - fig.savefig( - str(Path(OUTPUT_DIR) / name), - dpi=DPI, - bbox_inches="tight", - facecolor=BG, - ) - plt.close(fig) - logger.info(" \u2713 %s", name) - - -# ============================================================ -# 1. Topic-based subscription -# ============================================================ -def draw_sub_topic() -> None: - """Draw sub topic.""" - fig, ax = plt.subplots(1, 1, figsize=(FIG_W, 4.0)) - ax.set_xlim(0, 12) - ax.set_ylim(0, 5.5) - ax.set_aspect("equal") - ax.axis("off") - ax.set_title( - "Subskrypcja topic-based" - " \u2014 routing po nazwie tematu", - fontsize=FS_TITLE, - fontweight="bold", - pad=12, - ) - - bold10 = BoxStyle( - fill=GRAY1, fontsize=10, fontweight="bold" - ) - fs85 = BoxStyle(fill=GRAY1, fontsize=8.5) - - draw_box( - ax, (0.2, 3.2), (2.4, 1.1), "Publisher", bold10 - ) - draw_box( - ax, - (0.3, 1.8), - (2.2, 0.8), - 'topic: "orders"', - BoxStyle(fill=GRAY4, fontsize=8), - ) - draw_box( - ax, - (0.3, 0.7), - (2.2, 0.8), - 'topic: "payments"', - BoxStyle(fill=GRAY4, fontsize=8), - ) - - draw_box( - ax, - (4.2, 1.5), - (2.8, 2.2), - "BROKER\n\ntopic routing", - BoxStyle( - fill=GRAY2, fontsize=10, fontweight="bold" - ), - ) - - draw_box( - ax, - (8.5, 3.8), - (3.0, 1.0), - 'Subscriber A\nsubskrybuje: "orders"', - fs85, - ) - draw_box( - ax, - (8.5, 2.2), - (3.0, 1.0), - 'Subscriber B\nsubskrybuje: "payments"', - fs85, - ) - draw_box( - ax, - (8.5, 0.6), - (3.0, 1.0), - 'Subscriber C\nsubskrybuje: "orders"', - fs85, - ) - - fs8 = ArrowCfg(label_fs=8) - draw_arrow(ax, (2.6, 2.2), (4.2, 2.8), fs8) - draw_arrow(ax, (2.6, 1.1), (4.2, 2.2), fs8) - - draw_arrow( - ax, - (7.0, 3.4), - (8.5, 4.2), - ArrowCfg(label='"orders"', label_fs=8), - ) - draw_arrow( - ax, - (7.0, 2.6), - (8.5, 2.7), - ArrowCfg(label='"payments"', label_fs=8), - ) - draw_arrow( - ax, - (7.0, 2.2), - (8.5, 1.2), - ArrowCfg(label='"orders"', label_fs=8), - ) - - ax.text( - 6.0, - 0.1, - "Subscriber deklaruje nazw\u0119 tematu." - " Broker kieruje wiadomo\u015bci\n" - "do WSZYSTKICH subscriber\u00f3w" - " danego tematu. Najprostszy model.", - ha="center", - va="bottom", - fontsize=8.5, - style="italic", - bbox={ - "boxstyle": "round,pad=0.3", - "facecolor": GRAY4, - "edgecolor": GRAY3, - }, - ) - - save(fig, "pubsub_sub_topic.png") - - -# ============================================================ -# 2. Content-based subscription -# ============================================================ -def draw_sub_content() -> None: - """Draw sub content.""" - fig, ax = plt.subplots(1, 1, figsize=(FIG_W, 4.5)) - ax.set_xlim(0, 12) - ax.set_ylim(0, 6) - ax.set_aspect("equal") - ax.axis("off") - ax.set_title( - "Subskrypcja content-based" - " \u2014 filtrowanie po tre\u015bci wiadomo\u015bci", - fontsize=FS_TITLE, - fontweight="bold", - pad=12, - ) - - bold10 = BoxStyle( - fill=GRAY1, fontsize=10, fontweight="bold" - ) - draw_box( - ax, (0.2, 3.5), (2.4, 1.1), "Publisher", bold10 - ) - draw_box( - ax, - (0.2, 1.8), - (2.4, 1.2), - 'price: 150\ntype: "book"\ncategory: "IT"', - BoxStyle(fill=GRAY4, fontsize=8.5), - ) - - draw_box( - ax, - (4.0, 2.0), - (3.0, 2.5), - "BROKER\n\newaluuje filtry\n" - "ka\u017cdego subscribera", - BoxStyle( - fill=GRAY2, fontsize=9, fontweight="bold" - ), - ) - - fs9 = BoxStyle(fill=GRAY1, fontsize=9) - draw_box( - ax, - (8.5, 4.2), - (3.2, 1.0), - "Sub A\nfiltr: price > 100", - fs9, - ) - draw_box( - ax, - (8.5, 2.6), - (3.2, 1.0), - 'Sub B\nfiltr: type = "food"', - fs9, - ) - draw_box( - ax, - (8.5, 1.0), - (3.2, 1.0), - "Sub C\nfiltr: price < 50", - fs9, - ) - - draw_arrow(ax, (2.6, 2.4), (4.0, 3.0)) - draw_arrow( - ax, - (7.0, 4.0), - (8.5, 4.6), - ArrowCfg( - label="150 > 100 \u2713 dostarczono", - label_fs=8, - ), - ) - draw_dashed_arrow( - ax, - (7.0, 3.2), - (8.5, 3.1), - DashedCfg( - label='"book" \u2260 "food"' - " \u2717 odrzucono", - label_fs=8, - ), - ) - draw_dashed_arrow( - ax, - (7.0, 2.5), - (8.5, 1.6), - DashedCfg( - label="150 < 50 \u2717 odrzucono", - label_fs=8, - ), - ) - - ax.text( - 6.0, - 0.2, - "Broker analizuje TRE\u015a\u0106 wiadomo\u015bci" - " i ewaluuje predykaty.\n" - "Bardziej elastyczny ni\u017c topic-based," - " ale wolniejszy (koszt ewaluacji).", - ha="center", - va="bottom", - fontsize=8.5, - style="italic", - bbox={ - "boxstyle": "round,pad=0.3", - "facecolor": GRAY4, - "edgecolor": GRAY3, - }, - ) - - save(fig, "pubsub_sub_content.png") - - -# ============================================================ -# 3. Type-based subscription -# ============================================================ -def draw_sub_type() -> None: - """Draw sub type.""" - fig, ax = plt.subplots(1, 1, figsize=(FIG_W, 5.0)) - ax.set_xlim(0, 12) - ax.set_ylim(0, 6.5) - ax.set_aspect("equal") - ax.axis("off") - ax.set_title( - "Subskrypcja type-based" - " \u2014 routing po typie (klasie) obiektu", - fontsize=FS_TITLE, - fontweight="bold", - pad=12, - ) - - bold10 = BoxStyle( - fill=GRAY1, fontsize=10, fontweight="bold" - ) - draw_box( - ax, (0.2, 4.2), (2.4, 1.1), "Publisher", bold10 - ) - - fs9_g4 = BoxStyle(fill=GRAY4, fontsize=9) - draw_box( - ax, - (0.1, 2.8), - (2.6, 0.9), - "new OrderEvent()", - fs9_g4, - ) - draw_box( - ax, - (0.1, 1.5), - (2.6, 0.9), - "new PaymentEvent()", - fs9_g4, - ) - - draw_box( - ax, - (4.0, 2.3), - (3.0, 2.4), - "BROKER\n\nrouting po\ntypie klasy", - BoxStyle( - fill=GRAY2, fontsize=10, fontweight="bold" - ), - ) - - fs9 = BoxStyle(fill=GRAY1, fontsize=9) - draw_box( - ax, - (8.5, 4.8), - (3.2, 1.0), - "Sub A\n\u2192 OrderEvent", - fs9, - ) - draw_box( - ax, - (8.5, 3.2), - (3.2, 1.0), - "Sub B\n\u2192 PaymentEvent", - fs9, - ) - draw_box( - ax, - (8.5, 1.6), - (3.2, 1.0), - "Sub C\n\u2192 Event (base)", - fs9, - ) - - draw_arrow(ax, (2.7, 3.2), (4.0, 3.8)) - draw_arrow(ax, (2.7, 2.0), (4.0, 3.0)) - draw_arrow( - ax, - (7.0, 4.3), - (8.5, 5.2), - ArrowCfg(label="OrderEvent", label_fs=8), - ) - draw_arrow( - ax, - (7.0, 3.5), - (8.5, 3.7), - ArrowCfg(label="PaymentEvent", label_fs=8), - ) - draw_arrow( - ax, - (7.0, 3.0), - (8.5, 2.2), - ArrowCfg( - label="oba (dziedziczenie!)", label_fs=8 - ), - ) - - hx, hy = 0.5, 0.0 - draw_box( - ax, - (hx + 2.0, hy + 0.2), - (1.8, 0.6), - "Event", - BoxStyle( - fill=GRAY3, fontsize=8, fontweight="bold" - ), - ) - draw_box( - ax, - (hx, hy + 0.2), - (1.8, 0.6), - "OrderEvent", - BoxStyle(fill=GRAY4, fontsize=7.5), - ) - draw_box( - ax, - (hx + 4.0, hy + 0.2), - (2.0, 0.6), - "PaymentEvent", - BoxStyle(fill=GRAY4, fontsize=7.5), - ) - draw_arrow( - ax, - (hx + 2.9, hy + 0.2), - (hx + 0.9, hy + 0.2), - ArrowCfg( - lw=1.0, - label="extends", - label_offset=-0.3, - label_fs=7, - ), - ) - draw_arrow( - ax, - (hx + 2.9, hy + 0.2), - (hx + 5.0, hy + 0.2), - ArrowCfg( - lw=1.0, - label="extends", - label_offset=-0.3, - label_fs=7, - ), - ) - - ax.text( - 9.5, - 0.5, - "Sub C subskrybuje bazowy Event\n" - "\u2192 otrzymuje WSZYSTKIE podtypy", - ha="center", - va="center", - fontsize=8.5, - style="italic", - bbox={ - "boxstyle": "round,pad=0.3", - "facecolor": GRAY4, - "edgecolor": GRAY3, - }, - ) - - save(fig, "pubsub_sub_type.png") - - -# ============================================================ -# 4. Hierarchical / Wildcards subscription -# ============================================================ -def draw_sub_hierarchical() -> None: - """Draw sub hierarchical.""" - fig, ax = plt.subplots(1, 1, figsize=(FIG_W, 5.5)) - ax.set_xlim(0, 12) - ax.set_ylim(0, 7) - ax.set_aspect("equal") - ax.axis("off") - ax.set_title( - "Subskrypcja hierarchiczna (wildcards)" - " \u2014 wzorce temat\u00f3w", - fontsize=FS_TITLE, - fontweight="bold", - pad=12, - ) - - bold10 = BoxStyle( - fill=GRAY2, fontsize=10, fontweight="bold" - ) - draw_box( - ax, (4.5, 5.8), (2.4, 0.8), "sensors/", bold10 - ) - - fs9_g3 = BoxStyle(fill=GRAY3, fontsize=9) - draw_box( - ax, - (1.5, 4.2), - (2.4, 0.8), - "temperature/", - fs9_g3, - ) - draw_box( - ax, - (7.5, 4.2), - (2.4, 0.8), - "humidity/", - fs9_g3, - ) - - fs85_g4 = BoxStyle(fill=GRAY4, fontsize=8.5) - draw_box( - ax, (0.2, 2.8), (1.8, 0.7), "room1", fs85_g4 - ) - draw_box( - ax, (2.4, 2.8), (1.8, 0.7), "room2", fs85_g4 - ) - draw_box( - ax, (6.8, 2.8), (1.8, 0.7), "room1", fs85_g4 - ) - draw_box( - ax, (9.0, 2.8), (1.8, 0.7), "room2", fs85_g4 - ) - - thin = ArrowCfg(lw=1.0) - draw_arrow(ax, (5.7, 5.8), (2.7, 5.0), thin) - draw_arrow(ax, (5.7, 5.8), (8.7, 5.0), thin) - draw_arrow(ax, (2.2, 4.2), (1.1, 3.5), thin) - draw_arrow(ax, (3.2, 4.2), (3.3, 3.5), thin) - draw_arrow(ax, (8.2, 4.2), (7.7, 3.5), thin) - draw_arrow(ax, (9.2, 4.2), (9.9, 3.5), thin) - - ax.text( - 1.1, - 2.4, - "sensors/temperature/room1", - fontsize=7, - ha="center", - fontfamily="monospace", - style="italic", - ) - ax.text( - 3.3, - 2.4, - "sensors/temperature/room2", - fontsize=7, - ha="center", - fontfamily="monospace", - style="italic", - ) - - ax.text( - 0.3, - 1.5, - "Wzorce subskrypcji (MQTT-style):", - fontsize=10, - fontweight="bold", - ) - - patterns = [ - ( - '"sensors/temperature/room1"', - "\u2192 TYLKO room1", - "(dok\u0142adne dopasowanie)", - ), - ( - '"sensors/temperature/*"', - "\u2192 room1, room2", - "( * = jeden poziom)", - ), - ( - '"sensors/#"', - "\u2192 WSZYSTKO", - "( # = dowolna g\u0142\u0119boko\u015b\u0107)", - ), - ] - for i, (pat, result, note) in enumerate(patterns): - yy = 0.9 - i * 0.55 - ax.text( - 0.5, - yy, - pat, - fontsize=9, - fontweight="bold", - fontfamily="monospace", - ) - ax.text( - 7.0, yy, result, fontsize=9, fontweight="bold" - ) - ax.text( - 9.5, yy, note, fontsize=8, style="italic" - ) - - save(fig, "pubsub_sub_hierarchical.png") - - -# ============================================================ -# 5. At-most-once (QoS 0) -# ============================================================ -def draw_qos_at_most_once() -> None: - """Draw qos at most once.""" - fig, ax = plt.subplots(1, 1, figsize=(FIG_W, 4.5)) - ax.set_xlim(0, 12) - ax.set_ylim(0, 6) - ax.set_aspect("equal") - ax.axis("off") - ax.set_title( - "QoS: At-most-once" - " \u2014 \u201ewy\u015blij i zapomnij\u201d" - " (0 lub 1 dostarczenie)", - fontsize=FS_TITLE, - fontweight="bold", - pad=12, - ) - - px, bx, sx = 1.0, 4.8, 8.5 - pw, bw, sw = 2.0, 2.2, 2.0 - bh = 0.8 - bold10_g1 = BoxStyle( - fill=GRAY1, fontsize=10, fontweight="bold" - ) - bold10_g2 = BoxStyle( - fill=GRAY2, fontsize=10, fontweight="bold" - ) - draw_box( - ax, (px, 5.0), (pw, bh), "Publisher", bold10_g1 - ) - draw_box( - ax, (bx, 5.0), (bw, bh), "Broker", bold10_g2 - ) - draw_box( - ax, - (sx, 5.0), - (sw, bh), - "Subscriber", - bold10_g1, - ) - - for xc in [px + pw / 2, bx + bw / 2, sx + sw / 2]: - ax.plot( - [xc, xc], - [5.0, 1.2], - color=GRAY3, - lw=1, - linestyle=":", - ) - - # Scenario A: success - y = 4.3 - ax.text( - 0.2, - y + 0.15, - "Scenariusz A:", - fontsize=8.5, - fontweight="bold", - ) - msg9 = ArrowCfg(label="MSG", label_fs=9) - draw_arrow( - ax, (px + pw / 2, y), (bx + bw / 2, y), msg9 - ) - draw_arrow( - ax, - (bx + bw / 2, y - 0.6), - (sx + sw / 2, y - 0.6), - msg9, - ) - draw_check(ax, (sx + sw / 2 + 0.4, y - 0.6), size=0.18) - ax.text( - sx + sw / 2 + 0.7, - y - 0.6, - "OK", - fontsize=9, - fontweight="bold", - ) - - # Scenario B: lost - y = 2.6 - ax.text( - 0.2, - y + 0.15, - "Scenariusz B:", - fontsize=8.5, - fontweight="bold", - ) - draw_arrow( - ax, (px + pw / 2, y), (bx + bw / 2, y), msg9 - ) - draw_dashed_arrow( - ax, (bx + bw / 2, y - 0.6), (7.5, y - 0.6) - ) - draw_cross(ax, (7.8, y - 0.6), size=0.2) - ax.text( - 8.2, - y - 0.55, - "UTRACONA", - fontsize=9, - fontweight="bold", - ) - ax.text( - 8.2, - y - 1.0, - "(brak retransmisji)", - fontsize=8, - style="italic", - ) - - ax.text( - 6.0, - 0.5, - "Brak ACK, brak retransmisji." - " Najszybszy. Use case:" - " logi, metryki, telemetria.", - ha="center", - va="center", - fontsize=9, - bbox={ - "boxstyle": "round,pad=0.4", - "facecolor": GRAY4, - "edgecolor": GRAY3, - }, - ) - - save(fig, "pubsub_qos_at_most_once.png") - - -# ============================================================ -# 6. At-least-once (QoS 1) -# ============================================================ -def draw_qos_at_least_once() -> None: - """Draw qos at least once.""" - fig, ax = plt.subplots(1, 1, figsize=(FIG_W, 5.0)) - ax.set_xlim(0, 12) - ax.set_ylim(0, 6.5) - ax.set_aspect("equal") - ax.axis("off") - ax.set_title( - "QoS: At-least-once" - " \u2014 \u201epowtarzaj a\u017c potwierdz\u0105\u201d" - " (\u22651 dostarczenie)", - fontsize=FS_TITLE, - fontweight="bold", - pad=12, - ) - - bx, bw = 3.5, 2.2 - sx, sw = 8.0, 2.2 - bh = 0.8 - draw_box( - ax, - (bx, 5.5), - (bw, bh), - "Broker", - BoxStyle( - fill=GRAY2, fontsize=10, fontweight="bold" - ), - ) - draw_box( - ax, - (sx, 5.5), - (sw, bh), - "Subscriber", - BoxStyle( - fill=GRAY1, fontsize=10, fontweight="bold" - ), - ) - - for xc in [bx + bw / 2, sx + sw / 2]: - ax.plot( - [xc, xc], - [5.5, 0.8], - color=GRAY3, - lw=1, - linestyle=":", - ) - - # Step 1: send MSG - y1 = 4.8 - draw_arrow( - ax, - (bx + bw / 2, y1), - (sx + sw / 2, y1), - ArrowCfg(label="MSG #1", label_fs=9), - ) - draw_check(ax, (sx + sw + 0.2, y1), size=0.15) - ax.text(sx + sw + 0.5, y1, "odebrano", fontsize=8) - - # Step 2: ACK lost - y2 = 3.9 - draw_dashed_arrow( - ax, - (sx + sw / 2, y2), - (bx + bw + 1.2, y2), - ) - ax.text( - (bx + bw / 2 + sx + sw / 2) / 2, - y2 + 0.18, - "ACK", - fontsize=9, - ) - draw_cross(ax, (bx + bw + 0.8, y2), size=0.18) - ax.text( - bx + 0.3, - y2 - 0.35, - "ACK utracony!", - fontsize=8.5, - style="italic", - ) - - # Step 3: timeout -> retry - y3 = 2.9 - ax.text( - bx + bw / 2, - y3 + 0.45, - "timeout...", - fontsize=8.5, - style="italic", - ha="center", - ) - draw_arrow( - ax, - (bx + bw / 2, y3), - (sx + sw / 2, y3), - ArrowCfg(label="MSG #1 (retry)", label_fs=9), - ) - draw_check(ax, (sx + sw + 0.2, y3), size=0.15) - ax.text( - sx + sw + 0.5, - y3, - "odebrano\n(ponownie!)", - fontsize=8, - ) - - # Step 4: ACK ok - y4 = 2.0 - draw_arrow( - ax, - (sx + sw / 2, y4), - (bx + bw / 2, y4), - ArrowCfg(label="ACK", label_fs=9), - ) - draw_check(ax, (bx + bw / 2 - 0.5, y4), size=0.18) - - # Duplicate bracket - ax.annotate( - "", - xy=(sx + sw + 1.3, y1), - xytext=(sx + sw + 1.3, y3), - arrowprops={ - "arrowstyle": "<->", - "color": "black", - "lw": 1.2, - }, - ) - ax.text( - sx + sw + 1.6, - (y1 + y3) / 2, - "DUPLIKAT!\nSubscriber\notrzyma\u0142 2x", - fontsize=9, - ha="left", - va="center", - fontweight="bold", - bbox={ - "boxstyle": "round,pad=0.25", - "facecolor": GRAY4, - "edgecolor": GRAY3, - }, - ) - - ax.text( - 6.0, - 0.5, - "Broker czeka na ACK, retransmituje" - " po timeout. Mog\u0105 by\u0107 duplikaty!\n" - "Use case: zam\u00f3wienia, p\u0142atno\u015bci" - " (subscriber musi by\u0107 idempotentny).", - ha="center", - va="center", - fontsize=9, - bbox={ - "boxstyle": "round,pad=0.4", - "facecolor": GRAY4, - "edgecolor": GRAY3, - }, - ) - - save(fig, "pubsub_qos_at_least_once.png") - - -# ============================================================ -# 7. Exactly-once (QoS 2) -# ============================================================ -def draw_qos_exactly_once() -> None: - """Draw qos exactly once.""" - fig, ax = plt.subplots(1, 1, figsize=(FIG_W, 5.5)) - ax.set_xlim(0, 12) - ax.set_ylim(0, 7) - ax.set_aspect("equal") - ax.axis("off") - ax.set_title( - "QoS: Exactly-once \u2014 4-krokowy" - " handshake (dok\u0142adnie 1 dostarczenie)", - fontsize=FS_TITLE, - fontweight="bold", - pad=12, - ) - - bx, bw = 2.5, 2.2 - sx, sw = 7.5, 2.2 - bh = 0.8 - draw_box( - ax, - (bx, 6.0), - (bw, bh), - "Broker", - BoxStyle( - fill=GRAY2, fontsize=10, fontweight="bold" - ), - ) - draw_box( - ax, - (sx, 6.0), - (sw, bh), - "Subscriber", - BoxStyle( - fill=GRAY1, fontsize=10, fontweight="bold" - ), - ) - - for xc in [bx + bw / 2, sx + sw / 2]: - ax.plot( - [xc, xc], - [6.0, 1.0], - color=GRAY3, - lw=1, - linestyle=":", - ) - - steps = [ - ( - 5.2, - "right", - "PUBLISH (msg_id=42)", - "Broker wysy\u0142a wiadomo\u015b\u0107", - ), - ( - 4.2, - "left", - "PUBREC (otrzyma\u0142em id=42)", - "Sub potwierdza odbi\u00f3r," - " zapisuje id", - ), - ( - 3.2, - "right", - "PUBREL (mo\u017cesz przetworzy\u0107)", - "Broker zwalnia wiadomo\u015b\u0107", - ), - ( - 2.2, - "left", - "PUBCOMP (zako\u0144czone)", - "Sub potwierdza przetworzenie", - ), - ] - - for i, (y, direction, label, desc) in enumerate( - steps - ): - ax.text( - bx + bw / 2 - 0.7, - y, - f"{i + 1}", - fontsize=9, - fontweight="bold", - ha="center", - va="center", - bbox={ - "boxstyle": "circle,pad=0.18", - "facecolor": GRAY3, - "edgecolor": LN, - }, - ) - - if direction == "right": - draw_arrow( - ax, - (bx + bw / 2, y), - (sx + sw / 2, y), - ArrowCfg(label=label, label_fs=9), - ) - else: - draw_arrow( - ax, - (sx + sw / 2, y), - (bx + bw / 2, y), - ArrowCfg(label=label, label_fs=9), - ) - - ax.text( - sx + sw + 0.3, - y, - desc, - fontsize=8, - ha="left", - va="center", - style="italic", - ) - - ax.text( - 6.0, - 0.6, - "Deduplikacja po msg_id." - " Sub nie przetwarza przed PUBREL.\n" - "Najkosztowniejszy (4 pakiety)." - " Use case: transakcje finansowe," - " krytyczne zdarzenia.", - ha="center", - va="center", - fontsize=9, - bbox={ - "boxstyle": "round,pad=0.4", - "facecolor": GRAY4, - "edgecolor": GRAY3, - }, - ) - - save(fig, "pubsub_qos_exactly_once.png") - - # ============================================================ # Main # ============================================================ if __name__ == "__main__": - logger.info( - "Generating Pub/Sub diagrams" - " (7 separate images)..." - ) + logger.info("Generating Pub/Sub diagrams" " (7 separate images)...") draw_sub_topic() draw_sub_content() draw_sub_type() diff --git a/python_pkg/praca_magisterska_video/generate_images/generate_q20_diagrams.py b/python_pkg/praca_magisterska_video/generate_images/generate_q20_diagrams.py old mode 100755 new mode 100644 index 4893ad2..26902d2 --- a/python_pkg/praca_magisterska_video/generate_images/generate_q20_diagrams.py +++ b/python_pkg/praca_magisterska_video/generate_images/generate_q20_diagrams.py @@ -2,2125 +2,62 @@ """Generate ALL diagrams for PYTANIE 20: Analityka danych strumieniowych. Monochrome, A4-printable PNGs (300 DPI). +Re-exports all diagram generators from submodules. """ from __future__ import annotations import logging -from typing import TYPE_CHECKING - -import matplotlib as mpl - -mpl.use("Agg") from pathlib import Path +import sys -import matplotlib.patches as mpatches -from matplotlib.patches import FancyBboxPatch -import matplotlib.pyplot as plt -import numpy as np +# Ensure sibling modules are importable when run as a script. +sys.path.insert(0, str(Path(__file__).resolve().parent)) -if TYPE_CHECKING: - from matplotlib.axes import Axes - from matplotlib.figure import Figure +from _q20_architectures import ( + gen_exactly_once, + gen_lambda_kappa_table, + gen_lambda_vs_kappa, + gen_spark_streaming_arch, +) +from _q20_batch_and_windows import gen_batch_vs_streaming, gen_window_types +from _q20_late_and_decisions import gen_decision_tree, gen_late_data_strategies +from _q20_platforms import ( + gen_flink_arch, + gen_kafka_streams_arch, + gen_platform_comparison, + gen_streaming_ecosystem, + gen_true_vs_microbatch, +) +from _q20_time_monitoring_sessions import ( + gen_event_vs_processing_time, + gen_session_users, + gen_sliding_sla, + gen_tumbling_fraud, +) + +__all__ = [ + "gen_batch_vs_streaming", + "gen_decision_tree", + "gen_event_vs_processing_time", + "gen_exactly_once", + "gen_flink_arch", + "gen_kafka_streams_arch", + "gen_lambda_kappa_table", + "gen_lambda_vs_kappa", + "gen_late_data_strategies", + "gen_platform_comparison", + "gen_session_users", + "gen_sliding_sla", + "gen_spark_streaming_arch", + "gen_streaming_ecosystem", + "gen_true_vs_microbatch", + "gen_tumbling_fraud", + "gen_window_types", +] _logger = logging.getLogger(__name__) -rng = np.random.default_rng(42) - -DPI = 300 -BG = "white" -LN = "black" -FS = 8 -FS_TITLE = 11 -FS_SMALL = 6.5 -FS_LABEL = 9 -OUTPUT_DIR = str(Path(__file__).resolve().parent / "img") -Path(OUTPUT_DIR).mkdir(parents=True, exist_ok=True) - -GRAY1 = "#E8E8E8" -GRAY2 = "#D0D0D0" -GRAY3 = "#B8B8B8" -GRAY4 = "#F5F5F5" -GRAY5 = "#C0C0C0" - - -def draw_box( - ax: Axes, - x: float, - y: float, - w: float, - h: float, - text: str, - *, - fill: str = "white", - lw: float = 1.2, - fontsize: float = FS, - fontweight: str = "normal", - ha: str = "center", - va: str = "center", - rounded: bool = True, - edgecolor: str = LN, - linestyle: str = "-", -) -> None: - """Draw box.""" - if rounded: - rect = FancyBboxPatch( - (x, y), - w, - h, - boxstyle="round,pad=0.05", - lw=lw, - edgecolor=edgecolor, - facecolor=fill, - linestyle=linestyle, - ) - else: - rect = mpatches.Rectangle( - (x, y), - w, - h, - lw=lw, - edgecolor=edgecolor, - facecolor=fill, - linestyle=linestyle, - ) - ax.add_patch(rect) - ax.text( - x + w / 2, - y + h / 2, - text, - ha=ha, - va=va, - fontsize=fontsize, - fontweight=fontweight, - wrap=True, - ) - - -def draw_arrow( - ax: Axes, - x1: float, - y1: float, - x2: float, - y2: float, - lw: float = 1.2, - style: str = "->", - color: str = LN, -) -> None: - """Draw arrow.""" - ax.annotate( - "", - xy=(x2, y2), - xytext=(x1, y1), - arrowprops={"arrowstyle": style, "color": color, "lw": lw}, - ) - - -def save_fig(fig: Figure, name: str) -> None: - """Save fig.""" - path = str(Path(OUTPUT_DIR) / name) - fig.savefig(path, dpi=DPI, bbox_inches="tight", facecolor=BG, pad_inches=0.15) - plt.close(fig) - _logger.info(" Saved: %s", path) - - -def draw_table( - ax: Axes, - headers: list[str], - rows: list[list[str]], - x0: float, - y0: float, - col_widths: list[float], - row_h: float = 0.4, - header_fill: str = GRAY2, - row_fills: list[str] | None = None, - fontsize: float = FS, - header_fontsize: float | None = None, -) -> None: - """Draw table.""" - if header_fontsize is None: - header_fontsize = fontsize - len(headers) - # Header - cx = x0 - for j, hdr in enumerate(headers): - draw_box( - ax, - cx, - y0, - col_widths[j], - row_h, - hdr, - fill=header_fill, - fontsize=header_fontsize, - fontweight="bold", - rounded=False, - ) - cx += col_widths[j] - # Rows - for i, row in enumerate(rows): - cy = y0 - (i + 1) * row_h - cx = x0 - fill = GRAY4 if (i % 2 == 0) else "white" - if row_fills and i < len(row_fills): - fill = row_fills[i] - for j, cell in enumerate(row): - fw = "bold" if j == 0 else "normal" - draw_box( - ax, - cx, - cy, - col_widths[j], - row_h, - cell, - fill=fill, - fontsize=fontsize, - fontweight=fw, - rounded=False, - ) - cx += col_widths[j] - - -# ============================================================ -# 1. Batch vs Streaming concept -# ============================================================ -def gen_batch_vs_streaming() -> None: - """Gen batch vs streaming.""" - fig, axes = plt.subplots(2, 1, figsize=(9, 5)) - fig.suptitle( - "Batch vs Streaming — dwa modele przetwarzania", - fontsize=FS_TITLE, - fontweight="bold", - ) - - # Batch - ax = axes[0] - ax.set_xlim(0, 12) - ax.set_ylim(0, 3) - ax.set_aspect("auto") - ax.axis("off") - ax.set_title("BATCH (wsadowe)", fontsize=FS_LABEL, fontweight="bold") - - # Data collected - draw_box( - ax, - 0.5, - 0.8, - 3.0, - 1.4, - "Zbierz WSZYSTKIE\ndane\n(godziny / dni)", - fill=GRAY1, - fontsize=FS, - fontweight="bold", - ) - draw_arrow(ax, 3.5, 1.5, 4.5, 1.5, lw=2) - draw_box( - ax, - 4.5, - 0.8, - 2.5, - 1.4, - "Analiza\n(batch job)", - fill=GRAY2, - fontsize=FS, - fontweight="bold", - ) - draw_arrow(ax, 7.0, 1.5, 8.0, 1.5, lw=2) - draw_box( - ax, - 8.0, - 0.8, - 2.5, - 1.4, - "Wynik\n(jednorazowy)", - fill=GRAY3, - fontsize=FS, - fontweight="bold", - ) - ax.text(11.0, 1.5, "min-h", fontsize=FS, va="center", fontweight="bold") - - # Streaming - ax = axes[1] - ax.set_xlim(0, 12) - ax.set_ylim(0, 3) - ax.set_aspect("auto") - ax.axis("off") - ax.set_title("STREAMING (strumieniowe)", fontsize=FS_LABEL, fontweight="bold") - - # Events flowing - events_x = [0.5, 1.5, 2.5, 3.5] - for i, ex in enumerate(events_x): - draw_box( - ax, - ex, - 1.0, - 0.8, - 0.8, - f"e{i + 1}", - fill=GRAY4, - fontsize=FS, - fontweight="bold", - rounded=False, - ) - if i < len(events_x) - 1: - draw_arrow(ax, ex + 0.8, 1.4, ex + 1.0, 1.4, lw=1) - - ax.text(4.8, 1.4, "...", fontsize=FS_LABEL, va="center") - draw_arrow(ax, 5.2, 1.4, 5.8, 1.4, lw=2) - - draw_box( - ax, - 5.8, - 0.8, - 2.8, - 1.4, - "Analiza\nCIĄGŁA\n(event-by-event)", - fill=GRAY2, - fontsize=FS, - fontweight="bold", - ) - draw_arrow(ax, 8.6, 1.5, 9.3, 1.5, lw=2) - draw_box( - ax, - 9.3, - 0.8, - 2.0, - 1.4, - "Wyniki\nciągłe", - fill=GRAY3, - fontsize=FS, - fontweight="bold", - ) - ax.text(11.5, 0.5, "ms-s", fontsize=FS, va="center", fontweight="bold") - - # Arrow marking infinity - ax.annotate( - "", - xy=(0.2, 1.4), - xytext=(-0.3, 1.4), - arrowprops={"arrowstyle": "->", "lw": 1.5, "color": LN}, - ) - ax.text(0.0, 2.3, "∞ zdarzeń", fontsize=FS_SMALL, ha="center", style="italic") - - fig.tight_layout(rect=[0, 0, 1, 0.92]) - save_fig(fig, "q20_batch_vs_streaming.png") - - -# ============================================================ -# 2. All 4 window types (TSSG) -# ============================================================ -def _draw_tumbling_window(ax: Axes, events: list[int]) -> None: - """Draw tumbling window section.""" - ax.set_xlim(0, 14) - ax.set_ylim(0, 4) - ax.set_aspect("auto") - ax.axis("off") - ax.set_title( - "Tumbling Window (okno przerzutne) — rozłączne, stały rozmiar", - fontsize=FS_LABEL, - fontweight="bold", - ) - - # Time axis - ax.annotate( - "", - xy=(13.5, 1.0), - xytext=(0.3, 1.0), - arrowprops={"arrowstyle": "->", "lw": 1.5, "color": LN}, - ) - ax.text(13.5, 0.6, "czas", fontsize=FS_SMALL, ha="center") - - # Events - for i, e in enumerate(events): - x = 1.0 + i * 1.0 - ax.plot(x, 1.0, "ko", markersize=5) - ax.text(x, 0.5, f"e{e}", fontsize=FS_SMALL, ha="center") - - # Windows - colors_w = [GRAY1, GRAY3, GRAY1, GRAY3] - for w in range(4): - x_start = 1.0 + w * 3.0 - 0.3 - rect = mpatches.FancyBboxPatch( - (x_start, 1.5), - 3.0, - 1.2, - boxstyle="round,pad=0.1", - facecolor=colors_w[w], - edgecolor=LN, - lw=1.5, - ) - ax.add_patch(rect) - ax.text( - x_start + 1.5, - 2.1, - f"Okno {w + 1}", - fontsize=FS, - ha="center", - fontweight="bold", - ) - # Braces down to events - for j in range(3): - ex = 1.0 + w * 3.0 + j * 1.0 - ax.plot([ex, ex], [1.0, 1.5], color=LN, lw=0.8, linestyle="--") - - ax.text( - 7.0, - 3.2, - "Każde zdarzenie → DOKŁADNIE 1 okno. Zero nakładania.", - fontsize=FS, - ha="center", - style="italic", - bbox={"boxstyle": "round,pad=0.2", "facecolor": GRAY4, "edgecolor": GRAY5}, - ) - - - -def _draw_sliding_window(ax: Axes, events: list[int]) -> None: - """Draw sliding window section.""" - ax.set_xlim(0, 14) - ax.set_ylim(0, 5) - ax.set_aspect("auto") - ax.axis("off") - ax.set_title( - "Sliding Window (okno przesuwne) — nakładające, stały rozmiar + krok", - fontsize=FS_LABEL, - fontweight="bold", - ) - - ax.annotate( - "", - xy=(13.5, 1.0), - xytext=(0.3, 1.0), - arrowprops={"arrowstyle": "->", "lw": 1.5, "color": LN}, - ) - ax.text(13.5, 0.6, "czas", fontsize=FS_SMALL, ha="center") - - for i, e in enumerate(events[:8]): - x = 1.0 + i * 1.0 - ax.plot(x, 1.0, "ko", markersize=5) - ax.text(x, 0.5, f"e{e}", fontsize=FS_SMALL, ha="center") - - # Sliding windows: size=4, slide=2 - slide_colors = [GRAY1, GRAY2, GRAY3] - for w in range(3): - x_start = 0.7 + w * 2.0 - y_base = 1.5 + w * 0.9 - rect = mpatches.FancyBboxPatch( - (x_start, y_base), - 4.0, - 0.7, - boxstyle="round,pad=0.08", - facecolor=slide_colors[w], - edgecolor=LN, - lw=1.5, - alpha=0.7, - ) - ax.add_patch(rect) - ax.text( - x_start + 2.0, - y_base + 0.35, - f"Okno {w + 1} (size=4)", - fontsize=FS_SMALL, - ha="center", - fontweight="bold", - ) - - ax.text( - 10.5, - 3.5, - "krok=2\nNakładanie!\ne3,e4 → w oknie 1 i 2", - fontsize=FS, - ha="center", - style="italic", - bbox={"boxstyle": "round,pad=0.2", "facecolor": GRAY4, "edgecolor": GRAY5}, - ) - - - -def _draw_session_window(ax: Axes) -> None: - """Draw session window section.""" - ax.set_xlim(0, 14) - ax.set_ylim(0, 4) - ax.set_aspect("auto") - ax.axis("off") - ax.set_title( - "Session Window (okno sesji) — dynamiczny rozmiar, gap = przerwa", - fontsize=FS_LABEL, - fontweight="bold", - ) - - ax.annotate( - "", - xy=(13.5, 1.0), - xytext=(0.3, 1.0), - arrowprops={"arrowstyle": "->", "lw": 1.5, "color": LN}, - ) - ax.text(13.5, 0.6, "czas", fontsize=FS_SMALL, ha="center") - - # Cluster 1: events close together - cluster1 = [1.0, 1.8, 2.3, 3.0] - for x in cluster1: - ax.plot(x, 1.0, "ko", markersize=5) - - # Gap - ax.annotate( - "", - xy=(7.0, 0.7), - xytext=(4.0, 0.7), - arrowprops={"arrowstyle": "<->", "lw": 1, "color": LN}, - ) - ax.text( - 5.5, - 0.3, - "GAP > timeout", - fontsize=FS, - ha="center", - fontweight="bold", - style="italic", - ) - - # Cluster 2 - cluster2 = [8.0, 8.8, 9.5] - for x in cluster2: - ax.plot(x, 1.0, "ko", markersize=5) - - # Session boxes - rect1 = mpatches.FancyBboxPatch( - (0.7, 1.4), - 2.6, - 1.0, - boxstyle="round,pad=0.1", - facecolor=GRAY1, - edgecolor=LN, - lw=1.5, - ) - ax.add_patch(rect1) - ax.text( - 2.0, 1.9, "Sesja 1\n(4 zdarzenia)", fontsize=FS, ha="center", fontweight="bold" - ) - - rect2 = mpatches.FancyBboxPatch( - (7.7, 1.4), - 2.1, - 1.0, - boxstyle="round,pad=0.1", - facecolor=GRAY3, - edgecolor=LN, - lw=1.5, - ) - ax.add_patch(rect2) - ax.text( - 8.75, 1.9, "Sesja 2\n(3 zdarzenia)", fontsize=FS, ha="center", fontweight="bold" - ) - - ax.text( - 5.5, - 3.0, - "Nowa sesja po przerwie > gap", - fontsize=FS, - ha="center", - style="italic", - ) - - - -def _draw_global_window(ax: Axes) -> None: - """Draw global window section.""" - ax.set_xlim(0, 14) - ax.set_ylim(0, 4) - ax.set_aspect("auto") - ax.axis("off") - ax.set_title( - "Global Window — jedno okno na cały strumień + trigger", - fontsize=FS_LABEL, - fontweight="bold", - ) - - ax.annotate( - "", - xy=(13.5, 1.0), - xytext=(0.3, 1.0), - arrowprops={"arrowstyle": "->", "lw": 1.5, "color": LN}, - ) - ax.text(13.5, 0.6, "czas", fontsize=FS_SMALL, ha="center") - - for i in range(12): - x = 1.0 + i * 1.0 - ax.plot(x, 1.0, "ko", markersize=5) - - # One big window - rect = mpatches.FancyBboxPatch( - (0.5, 1.4), - 12.5, - 1.0, - boxstyle="round,pad=0.1", - facecolor=GRAY1, - edgecolor=LN, - lw=2, - ) - ax.add_patch(rect) - ax.text( - 6.75, - 1.9, - "GLOBAL WINDOW (cały strumień)", - fontsize=FS, - ha="center", - fontweight="bold", - ) - - # Trigger markers - for tx in [4.0, 8.0, 12.0]: - ax.plot([tx, tx], [1.4, 2.4], color=LN, lw=2, linestyle="--") - ax.text( - tx, - 2.7, - "EMIT", - fontsize=FS_SMALL, - ha="center", - fontweight="bold", - bbox={"boxstyle": "round,pad=0.1", "facecolor": GRAY3, "edgecolor": LN}, - ) - - ax.text( - 6.75, - 3.3, - "Trigger decyduje kiedy emitować (np. co N zdarzeń)", - fontsize=FS, - ha="center", - style="italic", - ) - - - -def gen_window_types() -> None: - """Gen window types.""" - fig, axes = plt.subplots(4, 1, figsize=(9, 10)) - fig.suptitle("4 typy okien — TSSG", fontsize=FS_TITLE, fontweight="bold") - - events = list(range(1, 13)) - - _draw_tumbling_window(axes[0], events) - _draw_sliding_window(axes[1], events) - _draw_session_window(axes[2]) - _draw_global_window(axes[3]) - - fig.tight_layout(rect=[0, 0, 1, 0.94]) - save_fig(fig, "q20_window_types.png") - -# ============================================================ -# 3. Event Time vs Processing Time scatter + watermark -# ============================================================ -def gen_event_vs_processing_time() -> None: - """Gen event vs processing time.""" - fig, axes = plt.subplots(1, 2, figsize=(11, 5)) - fig.suptitle( - "Event Time vs Processing Time + Watermark", - fontsize=FS_TITLE, - fontweight="bold", - ) - - # --- Panel 1: Ideal vs Real --- - ax = axes[0] - ax.set_xlim(0, 10) - ax.set_ylim(0, 10) - ax.set_aspect("equal") - ax.set_xlabel("Event Time", fontsize=FS_LABEL) - ax.set_ylabel("Processing Time", fontsize=FS_LABEL) - ax.set_title("Idealny vs Realny świat", fontsize=FS_LABEL, fontweight="bold") - ax.set_xticks([]) - ax.set_yticks([]) - - # Ideal line - ax.plot([0, 9], [0, 9], "k--", lw=1.5, label="ideał (brak opóźnień)") - - # Real scattered points (processing >= event, some out of order) - event_times = np.sort(rng.uniform(1, 8, 15)) - proc_times = event_times + rng.exponential(0.5, 15) - # Make some out of order - idx = [3, 7, 11] - for i in idx: - proc_times[i] += 1.5 - - ax.scatter( - event_times, proc_times, c="black", s=30, zorder=5, label="zdarzenia (realne)" - ) - - # Highlight out-of-order - for i in idx: - ax.annotate( - "out-of-order", - xy=(event_times[i], proc_times[i]), - xytext=(event_times[i] + 0.8, proc_times[i] + 0.5), - fontsize=FS_SMALL, - ha="left", - arrowprops={"arrowstyle": "->", "lw": 0.8, "color": "#555"}, - ) - - ax.legend(fontsize=FS_SMALL, loc="upper left") - ax.text( - 7, - 2, - "Opóźnienie\nsieciowe ↑", - fontsize=FS, - ha="center", - style="italic", - color="#555", - ) - - # --- Panel 2: Watermark concept --- - ax = axes[1] - ax.set_xlim(0, 10) - ax.set_ylim(0, 10) - ax.set_aspect("equal") - ax.set_xlabel("Event Time", fontsize=FS_LABEL) - ax.set_ylabel("Processing Time", fontsize=FS_LABEL) - ax.set_title("Watermark — granica postępu", fontsize=FS_LABEL, fontweight="bold") - ax.set_xticks([]) - ax.set_yticks([]) - - # Events - ax.scatter(event_times, proc_times, c="black", s=30, zorder=5) - - # Watermark line (below most points, tracks progress) - wm_x = np.linspace(0, 9, 50) - wm_y = wm_x + 0.3 # watermark slightly above ideal - ax.plot(wm_x, wm_y, "k-", lw=2.5, label="Watermark") - ax.fill_between(wm_x, 0, wm_y, alpha=0.15, color="gray") - - ax.text( - 2.0, - 1.0, - 'PONIŻEJ watermark:\n„na pewno dotarło"', - fontsize=FS, - ha="center", - fontweight="bold", - bbox={"boxstyle": "round,pad=0.2", "facecolor": GRAY4, "edgecolor": GRAY5}, - ) - - # Late event - late_x, late_y = event_times[7], proc_times[7] - ax.scatter( - [late_x], [late_y], c="white", s=80, zorder=6, edgecolors="black", linewidths=2 - ) - ax.annotate( - "LATE DATA!\n(po watermarku)", - xy=(late_x, late_y), - xytext=(late_x + 1.2, late_y + 0.8), - fontsize=FS_SMALL, - ha="left", - fontweight="bold", - arrowprops={"arrowstyle": "->", "lw": 1, "color": LN}, - ) - - ax.legend(fontsize=FS_SMALL, loc="upper left") - - fig.tight_layout(rect=[0, 0, 1, 0.92]) - save_fig(fig, "q20_event_vs_processing_time.png") - - -# ============================================================ -# 4. Tumbling window example — fraud detection -# ============================================================ -def gen_tumbling_fraud() -> None: - """Gen tumbling fraud.""" - fig, ax = plt.subplots(figsize=(9, 4)) - ax.set_xlim(0, 12) - ax.set_ylim(0, 5.5) - ax.set_aspect("auto") - ax.axis("off") - ax.set_title( - "Tumbling Window — fraud detection (okno = 1 min)", - fontsize=FS_TITLE, - fontweight="bold", - pad=10, - ) - - # Time axis - ax.annotate( - "", - xy=(11.5, 1.0), - xytext=(0.5, 1.0), - arrowprops={"arrowstyle": "->", "lw": 1.5, "color": LN}, - ) - ax.text(6.0, 0.4, "czas", fontsize=FS, ha="center") - - # Window 1: normal - draw_box(ax, 1.0, 1.5, 4.5, 3.0, "", fill=GRAY4, rounded=True, lw=2) - ax.text( - 3.25, 4.2, "[14:00 — 14:01]", fontsize=FS_LABEL, ha="center", fontweight="bold" - ) - # Transactions - txns1 = ["Sklep A: 50 zł", "Sklep B: 30 zł", "Stacja: 80 zł"] - for i, t in enumerate(txns1): - draw_box( - ax, - 1.3, - 3.3 - i * 0.55, - 4.0, - 0.45, - t, - fill=GRAY1, - fontsize=FS_SMALL, - rounded=False, - ) - ax.text( - 3.25, - 1.7, - "count = 3 → OK", - fontsize=FS, - ha="center", - fontweight="bold", - color="#2E7D32", - bbox={ - "boxstyle": "round,pad=0.15", - "facecolor": "#E8F5E9", - "edgecolor": "#2E7D32", - }, - ) - - # Window 2: fraud! - draw_box(ax, 6.0, 1.5, 4.5, 3.0, "", fill=GRAY1, rounded=True, lw=2) - ax.text( - 8.25, 4.2, "[14:01 — 14:02]", fontsize=FS_LABEL, ha="center", fontweight="bold" - ) - txns2 = ["ATM Warszawa: 500 zł", "ATM Kraków: 500 zł", "... +45 transakcji"] - for i, t in enumerate(txns2): - draw_box( - ax, - 6.3, - 3.3 - i * 0.55, - 4.0, - 0.45, - t, - fill=GRAY3, - fontsize=FS_SMALL, - rounded=False, - ) - ax.text( - 8.25, - 1.7, - "count = 47 → ALERT!", - fontsize=FS, - ha="center", - fontweight="bold", - color="#C62828", - bbox={ - "boxstyle": "round,pad=0.15", - "facecolor": "#F8D7DA", - "edgecolor": "#C62828", - }, - ) - - save_fig(fig, "q20_tumbling_fraud.png") - - -# ============================================================ -# 5. Sliding window — SLA monitoring -# ============================================================ -def gen_sliding_sla() -> None: - """Gen sliding sla.""" - fig, ax = plt.subplots(figsize=(9, 4.5)) - ax.set_xlim(0, 12) - ax.set_ylim(0, 6) - ax.set_aspect("auto") - ax.axis("off") - ax.set_title( - "Sliding Window — monitoring SLA (okno=5min, krok=1min)", - fontsize=FS_TITLE, - fontweight="bold", - pad=10, - ) - - # Time axis - ax.annotate( - "", - xy=(11.5, 0.5), - xytext=(0.5, 0.5), - arrowprops={"arrowstyle": "->", "lw": 1.5, "color": LN}, - ) - times = ["14:05", "14:06", "14:07", "14:08", "14:09"] - latencies = [120, 180, 340, 290, 150] - sla = 200 - - for i, (t, lat) in enumerate(zip(times, latencies, strict=False)): - x = 1.5 + i * 2.0 - ax.text(x, 0.1, t, fontsize=FS, ha="center") - - # Bar proportional to latency - bar_h = lat / 100.0 - is_breach = lat > sla - fill = "#F8D7DA" if is_breach else GRAY1 - edge = "#C62828" if is_breach else LN - draw_box( - ax, - x - 0.5, - 1.0, - 1.0, - bar_h, - "", - fill=fill, - rounded=False, - edgecolor=edge, - lw=1.5, - ) - ax.text( - x, - 1.0 + bar_h + 0.15, - f"{lat}ms", - fontsize=FS, - ha="center", - fontweight="bold", - color="#C62828" if is_breach else LN, - ) - - # Status - status = "ALERT!" if is_breach else "OK" - ax.text( - x, - 1.0 + bar_h + 0.55, - status, - fontsize=FS_SMALL, - ha="center", - fontweight="bold", - color="#C62828" if is_breach else "#2E7D32", - ) - - # SLA line - sla_y = 1.0 + sla / 100.0 - ax.plot([0.8, 11.2], [sla_y, sla_y], "k--", lw=1.5) - ax.text(11.3, sla_y, f"SLA={sla}ms", fontsize=FS, va="center", fontweight="bold") - - # Sliding window bracket - ax.annotate( - "", - xy=(1.0, 5.3), - xytext=(5.0, 5.3), - arrowprops={"arrowstyle": "<->", "lw": 1.5, "color": LN}, - ) - ax.text(3.0, 5.6, "okno = 5 min", fontsize=FS, ha="center", fontweight="bold") - - ax.annotate( - "", - xy=(3.0, 4.8), - xytext=(5.0, 4.8), - arrowprops={"arrowstyle": "<->", "lw": 1, "color": "#555"}, - ) - ax.text( - 4.0, - 4.4, - "krok = 1 min\n(nakładanie!)", - fontsize=FS_SMALL, - ha="center", - style="italic", - ) - - save_fig(fig, "q20_sliding_sla.png") - - -# ============================================================ -# 6. Session window — user sessions -# ============================================================ -def gen_session_users() -> None: - """Gen session users.""" - fig, axes = plt.subplots(2, 1, figsize=(10, 5)) - fig.suptitle( - "Session Window — sesje użytkowników (gap = 30 min)", - fontsize=FS_TITLE, - fontweight="bold", - ) - - # Anna: 2 sessions - ax = axes[0] - ax.set_xlim(0, 14) - ax.set_ylim(0, 3.5) - ax.set_aspect("auto") - ax.axis("off") - ax.set_title("Użytkownik Anna", fontsize=FS_LABEL, fontweight="bold") - - ax.annotate( - "", - xy=(13.5, 1.0), - xytext=(0.3, 1.0), - arrowprops={"arrowstyle": "->", "lw": 1.5, "color": LN}, - ) - - # Clicks cluster 1 - for x in [1.0, 1.8, 2.5, 3.2]: - ax.plot(x, 1.0, "ko", markersize=6) - # Clicks cluster 2 - for x in [9.0, 9.8, 10.5]: - ax.plot(x, 1.0, "ko", markersize=6) - - # Sessions - rect1 = mpatches.FancyBboxPatch( - (0.7, 1.5), - 2.8, - 1.2, - boxstyle="round,pad=0.1", - facecolor=GRAY1, - edgecolor=LN, - lw=1.5, - ) - ax.add_patch(rect1) - ax.text( - 2.1, - 2.1, - "Sesja 1\n4 kliknięcia, 12 min", - fontsize=FS, - ha="center", - fontweight="bold", - ) - - rect2 = mpatches.FancyBboxPatch( - (8.7, 1.5), - 2.1, - 1.2, - boxstyle="round,pad=0.1", - facecolor=GRAY3, - edgecolor=LN, - lw=1.5, - ) - ax.add_patch(rect2) - ax.text( - 9.75, - 2.1, - "Sesja 2\n3 kliknięcia, 8 min", - fontsize=FS, - ha="center", - fontweight="bold", - ) - - # Gap - ax.annotate( - "", - xy=(8.5, 0.5), - xytext=(3.8, 0.5), - arrowprops={"arrowstyle": "<->", "lw": 1.5, "color": LN}, - ) - ax.text( - 6.15, - 0.1, - "cisza 45 min > gap(30)", - fontsize=FS, - ha="center", - fontweight="bold", - style="italic", - ) - - # Bob: 1 session - ax = axes[1] - ax.set_xlim(0, 14) - ax.set_ylim(0, 3.5) - ax.set_aspect("auto") - ax.axis("off") - ax.set_title("Użytkownik Bob", fontsize=FS_LABEL, fontweight="bold") - - ax.annotate( - "", - xy=(13.5, 1.0), - xytext=(0.3, 1.0), - arrowprops={"arrowstyle": "->", "lw": 1.5, "color": LN}, - ) - - # Clicks spread evenly - bobs = [1.0, 2.5, 4.0, 5.5, 7.0, 8.5, 10.0] - for x in bobs: - ax.plot(x, 1.0, "ko", markersize=6) - - rect = mpatches.FancyBboxPatch( - (0.7, 1.5), - 9.6, - 1.2, - boxstyle="round,pad=0.1", - facecolor=GRAY1, - edgecolor=LN, - lw=2, - ) - ax.add_patch(rect) - ax.text( - 5.5, - 2.1, - "Sesja 1 (ciągła) — 7 kliknięć, każde < 30 min od poprzedniego", - fontsize=FS, - ha="center", - fontweight="bold", - ) - - fig.tight_layout(rect=[0, 0, 1, 0.92]) - save_fig(fig, "q20_session_users.png") - - -# ============================================================ -# 7. Streaming ecosystem overview -# ============================================================ -def gen_streaming_ecosystem() -> None: - """Gen streaming ecosystem.""" - fig, ax = plt.subplots(figsize=(10, 5.5)) - ax.set_xlim(0, 12) - ax.set_ylim(0, 7) - ax.set_aspect("auto") - ax.axis("off") - ax.set_title( - "Ekosystem przetwarzania strumieniowego", - fontsize=FS_TITLE, - fontweight="bold", - pad=10, - ) - - # Source - draw_box( - ax, - 0.3, - 2.5, - 2.0, - 3.0, - "Kafka\nTopics\n(źródło)", - fill=GRAY2, - fontsize=FS, - fontweight="bold", - ) - - # Engines - engines = [ - ("Kafka Streams\n(library w JVM)", GRAY1, 4.7), - ("Apache Flink\n(klaster)", GRAY3, 3.2), - ("Spark Streaming\n(klaster)", GRAY5, 1.7), - ] - for label, color, y in engines: - draw_box( - ax, 4.0, y, 3.0, 1.2, label, fill=color, fontsize=FS, fontweight="bold" - ) - draw_arrow(ax, 2.3, 4.0, 4.0, y + 0.6, lw=1.5) - - # Sinks - sinks = [ - ("Kafka topic\n/ baza danych", GRAY4, 4.7), - ("DB / Kafka\n/ S3", GRAY4, 3.2), - ("HDFS / DB\n/ dashboard", GRAY4, 1.7), - ] - for label, color, y in sinks: - draw_box(ax, 8.5, y, 2.5, 1.2, label, fill=color, fontsize=FS) - draw_arrow(ax, 7.0, y + 0.6, 8.5, y + 0.6, lw=1.5) - - # Labels - ax.text(1.3, 6.0, "ŹRÓDŁO", fontsize=FS_LABEL, ha="center", fontweight="bold") - ax.text(5.5, 6.2, "SILNIK", fontsize=FS_LABEL, ha="center", fontweight="bold") - ax.text(9.75, 6.2, "WYNIK", fontsize=FS_LABEL, ha="center", fontweight="bold") - - # Latency annotations - ax.text(5.5, 5.95, "~1-10 ms", fontsize=FS_SMALL, ha="center", style="italic") - ax.text(5.5, 4.5, "<10 ms", fontsize=FS_SMALL, ha="center", style="italic") - ax.text(5.5, 3.0, "~100 ms", fontsize=FS_SMALL, ha="center", style="italic") - - save_fig(fig, "q20_streaming_ecosystem.png") - - -# ============================================================ -# 8. True streaming vs Micro-batch -# ============================================================ -def gen_true_vs_microbatch() -> None: - """Gen true vs microbatch.""" - fig, axes = plt.subplots(2, 1, figsize=(10, 5.5)) - fig.suptitle("True Streaming vs Micro-Batch", fontsize=FS_TITLE, fontweight="bold") - - # True streaming - ax = axes[0] - ax.set_xlim(0, 12) - ax.set_ylim(0, 3.5) - ax.set_aspect("auto") - ax.axis("off") - ax.set_title( - "TRUE STREAMING (Flink, Kafka Streams) — event-by-event", - fontsize=FS_LABEL, - fontweight="bold", - ) - - for i in range(6): - x = 1.0 + i * 1.8 - # Event - draw_box( - ax, - x, - 2.0, - 0.8, - 0.7, - f"e{i + 1}", - fill=GRAY1, - fontsize=FS, - fontweight="bold", - rounded=False, - ) - # Arrow down - draw_arrow(ax, x + 0.4, 2.0, x + 0.4, 1.4, lw=1) - # Result - draw_box( - ax, - x, - 0.5, - 0.8, - 0.7, - f"r{i + 1}", - fill=GRAY3, - fontsize=FS, - fontweight="bold", - rounded=False, - ) - # Latency label - ax.text(x + 0.4, 1.6, "~ms", fontsize=5, ha="center", color="#555") - - ax.text( - 11.5, - 1.3, - "Latencja:\n< 10 ms", - fontsize=FS, - ha="center", - fontweight="bold", - bbox={"boxstyle": "round,pad=0.2", "facecolor": GRAY4, "edgecolor": GRAY5}, - ) - - # Micro-batch - ax = axes[1] - ax.set_xlim(0, 12) - ax.set_ylim(0, 3.5) - ax.set_aspect("auto") - ax.axis("off") - ax.set_title( - "MICRO-BATCH (Spark Streaming) — grupami co ~100ms", - fontsize=FS_LABEL, - fontweight="bold", - ) - - batch_colors = [GRAY1, GRAY2, GRAY3] - for b in range(3): - bx = 0.8 + b * 3.5 - # Batch boundary - draw_box(ax, bx, 1.8, 3.0, 1.0, "", fill=batch_colors[b], rounded=True, lw=1.5) - ax.text( - bx + 1.5, 2.6, f"Batch {b + 1}", fontsize=FS, ha="center", fontweight="bold" - ) - for j in range(3): - ex = bx + 0.3 + j * 0.9 - draw_box( - ax, - ex, - 2.0, - 0.7, - 0.5, - f"e{b * 3 + j + 1}", - fill="white", - fontsize=FS_SMALL, - rounded=False, - ) - - # Arrow down - draw_arrow(ax, bx + 1.5, 1.8, bx + 1.5, 1.2, lw=1.5) - # Result - draw_box( - ax, - bx + 0.5, - 0.4, - 2.0, - 0.7, - f"result {b + 1}", - fill=GRAY4, - fontsize=FS, - fontweight="bold", - ) - - ax.text( - 11.5, - 1.3, - "Latencja:\n~100ms-s", - fontsize=FS, - ha="center", - fontweight="bold", - bbox={"boxstyle": "round,pad=0.2", "facecolor": GRAY4, "edgecolor": GRAY5}, - ) - - fig.tight_layout(rect=[0, 0, 1, 0.92]) - save_fig(fig, "q20_true_vs_microbatch.png") - - -# ============================================================ -# 9. Platform comparison table -# ============================================================ -def gen_platform_comparison() -> None: - """Gen platform comparison.""" - fig, ax = plt.subplots(figsize=(9, 5)) - ax.set_xlim(0, 11.5) - ax.set_ylim(-6, 1) - ax.set_aspect("auto") - ax.axis("off") - ax.set_title( - "Porównanie platform strumieniowych", - fontsize=FS_TITLE, - fontweight="bold", - pad=10, - ) - - headers = ["Cecha", "Kafka Streams", "Apache Flink", "Spark Streaming"] - col_w = [2.5, 2.8, 2.8, 2.8] - rows = [ - ["Model", "event-by-event", "event-by-event", "micro-batch (~100ms)"], - ["Deployment", "library (w JVM)", "klaster", "klaster"], - ["Latencja", "~1-10 ms", "< 10 ms", "100 ms - sekundy"], - ["Exactly-once", "Kafka TXN", "checkpointing", "WAL"], - ["State", "RocksDB local", "RocksDB + ckpt", "in-memory / ext"], - ["Okna", "T, S, Session", "wszystkie + custom", "T, S"], - ["Use case", "Kafka → Kafka", "złożona analityka", "ETL + ML / SQL"], - ] - draw_table( - ax, - headers, - rows, - x0=0.25, - y0=0.5, - col_widths=col_w, - row_h=0.6, - fontsize=7, - header_fontsize=8, - ) - - save_fig(fig, "q20_platform_comparison.png") - - -# ============================================================ -# 10. Kafka Streams architecture -# ============================================================ -def gen_kafka_streams_arch() -> None: - """Gen kafka streams arch.""" - fig, ax = plt.subplots(figsize=(9, 5)) - ax.set_xlim(0, 12) - ax.set_ylim(0, 7) - ax.set_aspect("auto") - ax.axis("off") - ax.set_title( - "Kafka Streams — architektura (library w JVM)", - fontsize=FS_TITLE, - fontweight="bold", - pad=10, - ) - - # Outer box: Your Java application - draw_box(ax, 0.5, 0.5, 11.0, 5.5, "", fill=GRAY4, rounded=True, lw=2.5) - ax.text( - 6.0, - 5.7, - "Twoja aplikacja Java (JVM)", - fontsize=FS_LABEL, - ha="center", - fontweight="bold", - ) - - # Kafka Consumer - draw_box( - ax, - 1.0, - 3.0, - 2.5, - 1.5, - "Kafka\nConsumer\n(input topic)", - fill=GRAY1, - fontsize=FS, - fontweight="bold", - ) - - # Processing - draw_box( - ax, - 4.5, - 3.0, - 2.5, - 1.5, - "Kafka Streams\n(logika\nbiznesowa)", - fill=GRAY2, - fontsize=FS, - fontweight="bold", - ) - - # Kafka Producer - draw_box( - ax, - 8.0, - 3.0, - 2.5, - 1.5, - "Kafka\nProducer\n(output topic)", - fill=GRAY1, - fontsize=FS, - fontweight="bold", - ) - - # Arrows - draw_arrow(ax, 3.5, 3.75, 4.5, 3.75, lw=2) - draw_arrow(ax, 7.0, 3.75, 8.0, 3.75, lw=2) - - # RocksDB state store - draw_box( - ax, - 4.5, - 1.0, - 2.5, - 1.3, - "RocksDB\n(stan lokalny)", - fill=GRAY3, - fontsize=FS, - fontweight="bold", - ) - ax.plot([5.75, 5.75], [3.0, 2.3], color=LN, lw=1.5) - ax.text( - 7.3, - 1.6, - "okna, joiny,\nagregacje", - fontsize=FS_SMALL, - style="italic", - va="center", - ) - - # Key message - ax.text( - 6.0, - 0.2, - "NIE potrzebujesz osobnego klastra! Skalujesz = więcej instancji JVM.", - fontsize=FS, - ha="center", - fontweight="bold", - bbox={"boxstyle": "round,pad=0.2", "facecolor": "white", "edgecolor": LN}, - ) - - save_fig(fig, "q20_kafka_streams_arch.png") - - -# ============================================================ -# 11. Flink architecture + checkpointing -# ============================================================ -def gen_flink_arch() -> None: - """Gen flink arch.""" - fig, ax = plt.subplots(figsize=(9, 6)) - ax.set_xlim(0, 12) - ax.set_ylim(0, 8) - ax.set_aspect("auto") - ax.axis("off") - ax.set_title( - "Apache Flink — architektura klastra + checkpointing", - fontsize=FS_TITLE, - fontweight="bold", - pad=10, - ) - - # Cluster border - draw_box(ax, 0.3, 1.0, 11.4, 6.2, "", fill=GRAY4, rounded=True, lw=2.5) - ax.text( - 6.0, 6.95, "FLINK CLUSTER", fontsize=FS_LABEL, ha="center", fontweight="bold" - ) - - # Job Manager - draw_box( - ax, - 1.0, - 5.5, - 3.0, - 1.2, - "Job Manager\n(koordynacja,\ncheckpointy)", - fill=GRAY2, - fontsize=FS, - fontweight="bold", - ) - - # Task Managers - draw_box(ax, 1.0, 3.0, 10.0, 2.0, "", fill="white", rounded=True, lw=1.5) - ax.text( - 6.0, 4.7, "Task Managers (workery)", fontsize=FS, ha="center", fontweight="bold" - ) - - slots = ["source\n& map()", "map()", "window()\n& reduce", "sink()"] - for i, s in enumerate(slots): - x = 1.5 + i * 2.4 - draw_box( - ax, - x, - 3.3, - 2.0, - 1.2, - f"Slot {i + 1}\n{s}", - fill=GRAY1, - fontsize=FS_SMALL, - fontweight="bold", - ) - - draw_arrow(ax, 2.5, 5.5, 6.0, 5.0, lw=1.5, style="->") - ax.text(5.0, 5.5, "przydziela\npodzadania", fontsize=FS_SMALL, style="italic") - - # Checkpoint storage - draw_box( - ax, - 5.5, - 1.2, - 3.5, - 1.2, - "Checkpoint Storage\n(HDFS / S3)", - fill=GRAY3, - fontsize=FS, - fontweight="bold", - ) - ax.plot([7.25, 7.25], [2.4, 3.3], color=LN, lw=1.5, linestyle="--") - ax.text(8.0, 2.7, "snapshoty\nstanu", fontsize=FS_SMALL, style="italic") - - # Barrier concept at bottom - ax.text(3.0, 1.6, "Barrier:", fontsize=FS, fontweight="bold") - barrier_boxes = ["source", "|B|", "map", "|B|", "sink"] - bx = 0.8 - for _i, b in enumerate(barrier_boxes): - if b == "|B|": - ax.text( - bx + 0.3, - 1.5, - b, - fontsize=FS, - ha="center", - fontweight="bold", - bbox={"boxstyle": "round,pad=0.1", "facecolor": GRAY5, "edgecolor": LN}, - ) - draw_arrow(ax, bx, 1.5, bx + 0.1, 1.5, lw=1) - bx += 0.7 - else: - draw_box( - ax, - bx, - 1.3, - 1.0, - 0.45, - b, - fill=GRAY1, - fontsize=FS_SMALL, - fontweight="bold", - ) - bx += 1.2 - - save_fig(fig, "q20_flink_arch.png") - - -# ============================================================ -# 12. Spark Streaming architecture -# ============================================================ -def gen_spark_streaming_arch() -> None: - """Gen spark streaming arch.""" - fig, ax = plt.subplots(figsize=(9, 5)) - ax.set_xlim(0, 12) - ax.set_ylim(0, 7) - ax.set_aspect("auto") - ax.axis("off") - ax.set_title( - "Spark Streaming — architektura (micro-batch)", - fontsize=FS_TITLE, - fontweight="bold", - pad=10, - ) - - # Cluster border - draw_box(ax, 0.3, 0.5, 11.4, 5.8, "", fill=GRAY4, rounded=True, lw=2.5) - ax.text( - 6.0, 6.0, "SPARK CLUSTER", fontsize=FS_LABEL, ha="center", fontweight="bold" - ) - - # Driver - draw_box( - ax, - 1.0, - 4.5, - 3.0, - 1.2, - "Driver\n(planuje mini-batche)", - fill=GRAY2, - fontsize=FS, - fontweight="bold", - ) - - draw_arrow(ax, 2.5, 4.5, 6.0, 4.0, lw=1.5) - - # Batches - batches = ["batch 1\n(e1,e2,e3)", "batch 2\n(e4,e5,e6)", "batch 3\n(e7,e8,e9)"] - for i, b in enumerate(batches): - y = 2.8 - i * 1.0 - draw_box( - ax, 4.5, y, 2.5, 0.8, b, fill=GRAY1, fontsize=FS_SMALL, fontweight="bold" - ) - # map → reduce - draw_arrow(ax, 7.0, y + 0.4, 7.5, y + 0.4, lw=1) - draw_box(ax, 7.5, y, 1.3, 0.8, "map→\nreduce", fill=GRAY3, fontsize=5.5) - draw_arrow(ax, 8.8, y + 0.4, 9.3, y + 0.4, lw=1) - draw_box( - ax, 9.3, y, 1.5, 0.8, f"result {i + 1}", fill="white", fontsize=FS_SMALL - ) - - # Spark ecosystem - draw_box( - ax, - 1.0, - 1.0, - 3.0, - 1.0, - "Spark SQL / MLlib\n(ten sam ekosystem!)", - fill=GRAY5, - fontsize=FS, - fontweight="bold", - ) - - ax.text( - 6.0, - 0.3, - "ZALETA: batch API | WADA: latencja ≥ batch interval (~100ms)", - fontsize=FS, - ha="center", - fontweight="bold", - bbox={"boxstyle": "round,pad=0.2", "facecolor": "white", "edgecolor": LN}, - ) - - save_fig(fig, "q20_spark_streaming_arch.png") - - -# ============================================================ -# 13. Lambda vs Kappa architecture -# ============================================================ -def gen_lambda_vs_kappa() -> None: - """Gen lambda vs kappa.""" - fig, axes = plt.subplots(2, 1, figsize=(10, 7)) - fig.suptitle("Architektura Lambda vs Kappa", fontsize=FS_TITLE, fontweight="bold") - - # --- Lambda --- - ax = axes[0] - ax.set_xlim(0, 12) - ax.set_ylim(0, 5) - ax.set_aspect("auto") - ax.axis("off") - ax.set_title( - "LAMBDA — 2 ścieżki (batch + speed)", fontsize=FS_LABEL, fontweight="bold" - ) - - # Source - draw_box( - ax, - 0.3, - 1.8, - 2.0, - 1.5, - "Źródło\ndanych", - fill=GRAY2, - fontsize=FS, - fontweight="bold", - ) - - # Batch layer (top) - draw_box( - ax, - 3.5, - 3.3, - 3.0, - 1.2, - "Batch Layer\n(Spark)\nprzelicza co godzinę", - fill=GRAY1, - fontsize=FS_SMALL, - fontweight="bold", - ) - draw_arrow(ax, 2.3, 3.0, 3.5, 3.9, lw=1.5) - - # Speed layer (bottom) - draw_box( - ax, - 3.5, - 0.8, - 3.0, - 1.2, - "Speed Layer\n(Flink)\nreal-time", - fill=GRAY3, - fontsize=FS_SMALL, - fontweight="bold", - ) - draw_arrow(ax, 2.3, 2.2, 3.5, 1.4, lw=1.5) - - # Results - draw_box( - ax, - 7.5, - 3.3, - 2.0, - 1.2, - "Dokładne\nwyniki\n(wolne)", - fill=GRAY4, - fontsize=FS_SMALL, - ) - draw_arrow(ax, 6.5, 3.9, 7.5, 3.9, lw=1.5) - - draw_box( - ax, - 7.5, - 0.8, - 2.0, - 1.2, - "Przybliżone\nwyniki\n(szybkie)", - fill=GRAY4, - fontsize=FS_SMALL, - ) - draw_arrow(ax, 6.5, 1.4, 7.5, 1.4, lw=1.5) - - # Merge - draw_box( - ax, - 10.0, - 2.0, - 1.5, - 1.5, - "MERGE\n→ UI", - fill=GRAY5, - fontsize=FS, - fontweight="bold", - ) - draw_arrow(ax, 9.5, 3.5, 10.0, 3.0, lw=1.5) - draw_arrow(ax, 9.5, 1.8, 10.0, 2.5, lw=1.5) - - ax.text( - 6.0, - 0.1, - "2 systemy, 2 kody — złożone ale pewne", - fontsize=FS, - ha="center", - style="italic", - bbox={"boxstyle": "round,pad=0.2", "facecolor": GRAY4, "edgecolor": GRAY5}, - ) - - # --- Kappa --- - ax = axes[1] - ax.set_xlim(0, 12) - ax.set_ylim(0, 4) - ax.set_aspect("auto") - ax.axis("off") - ax.set_title( - "KAPPA — 1 ścieżka (streaming only)", fontsize=FS_LABEL, fontweight="bold" - ) - - # Source - draw_box( - ax, - 0.3, - 1.3, - 2.0, - 1.5, - "Źródło\ndanych", - fill=GRAY2, - fontsize=FS, - fontweight="bold", - ) - - # Single streaming layer - draw_box( - ax, - 3.5, - 1.3, - 3.5, - 1.5, - "Streaming Layer\n(Flink)\n+ replay z Kafka log", - fill=GRAY1, - fontsize=FS, - fontweight="bold", - ) - draw_arrow(ax, 2.3, 2.05, 3.5, 2.05, lw=2) - - # Output - draw_box( - ax, - 8.0, - 1.3, - 2.5, - 1.5, - "Wyniki\n→ UI", - fill=GRAY4, - fontsize=FS, - fontweight="bold", - ) - draw_arrow(ax, 7.0, 2.05, 8.0, 2.05, lw=2) - - # Replay arrow - ax.annotate( - "", - xy=(3.5, 1.0), - xytext=(7.0, 1.0), - arrowprops={ - "arrowstyle": "<-", - "lw": 1.5, - "color": LN, - "connectionstyle": "arc3,rad=0.3", - "linestyle": "--", - }, - ) - ax.text( - 5.25, - 0.3, - "Replay z Kafka\n(przetwórz historię od nowa)", - fontsize=FS_SMALL, - ha="center", - style="italic", - ) - - ax.text( - 6.0, - 3.3, - "1 system, 1 kod — prostsze, ale replay = dużo I/O", - fontsize=FS, - ha="center", - style="italic", - bbox={"boxstyle": "round,pad=0.2", "facecolor": GRAY4, "edgecolor": GRAY5}, - ) - - fig.tight_layout(rect=[0, 0, 1, 0.92]) - save_fig(fig, "q20_lambda_vs_kappa.png") - - -# ============================================================ -# 14. Lambda vs Kappa comparison table -# ============================================================ -def gen_lambda_kappa_table() -> None: - """Gen lambda kappa table.""" - fig, ax = plt.subplots(figsize=(8, 3.5)) - ax.set_xlim(0, 10) - ax.set_ylim(-4.5, 1) - ax.set_aspect("auto") - ax.axis("off") - ax.set_title( - "Lambda vs Kappa — porównanie", fontsize=FS_TITLE, fontweight="bold", pad=10 - ) - - headers = ["Cecha", "Lambda", "Kappa"] - col_w = [2.5, 3.5, 3.5] - rows = [ - ["Ścieżki", "2 (batch + speed)", "1 (streaming)"], - ["Kod", "2 implementacje", "1 implementacja"], - ["Złożoność", "wysoka", "niska"], - ["Replay", "batch przelicza", "Kafka replay"], - ["Spójność", "merge wymagany", "natywna"], - ["Przykład", "Netflix, LinkedIn", "Uber, Confluent"], - ] - draw_table( - ax, headers, rows, x0=0.25, y0=0.5, col_widths=col_w, row_h=0.55, fontsize=7.5 - ) - - save_fig(fig, "q20_lambda_kappa_table.png") - - -# ============================================================ -# 15. Exactly-once comparison -# ============================================================ -def gen_exactly_once() -> None: - """Gen exactly once.""" - fig, ax = plt.subplots(figsize=(9, 5)) - ax.set_xlim(0, 12) - ax.set_ylim(0, 7) - ax.set_aspect("auto") - ax.axis("off") - ax.set_title( - "Exactly-Once — mechanizmy na 3 platformach", - fontsize=FS_TITLE, - fontweight="bold", - pad=10, - ) - - # Flink - draw_box(ax, 0.3, 4.3, 11.0, 2.0, "", fill=GRAY4, rounded=True, lw=1.5) - ax.text( - 1.0, - 5.9, - "Flink — Distributed Snapshots (Chandy-Lamport)", - fontsize=FS, - fontweight="bold", - ) - - flink_steps = ["source", "|B|", "map()", "|B|", "sink()"] - bx = 1.0 - for s in flink_steps: - if s == "|B|": - ax.text( - bx + 0.25, - 4.85, - s, - fontsize=FS, - ha="center", - fontweight="bold", - bbox={"boxstyle": "round,pad=0.1", "facecolor": GRAY5, "edgecolor": LN}, - ) - draw_arrow(ax, bx - 0.1, 4.85, bx + 0.05, 4.85, lw=1) - bx += 0.7 - else: - draw_box( - ax, - bx, - 4.6, - 1.5, - 0.55, - s, - fill=GRAY1, - fontsize=FS_SMALL, - fontweight="bold", - ) - bx += 1.8 - ax.text( - 8.5, - 5.0, - "barrier → save state\n→ checkpoint (HDFS/S3)", - fontsize=FS_SMALL, - style="italic", - ) - - # Kafka Streams - draw_box(ax, 0.3, 2.3, 11.0, 1.5, "", fill=GRAY1, rounded=True, lw=1.5) - ax.text( - 1.0, 3.5, "Kafka Streams — Transakcje Kafka", fontsize=FS, fontweight="bold" - ) - ax.text( - 1.5, - 2.85, - "idempotent producer + begin TX → produce → commit TX → consumer offsets w TX", - fontsize=FS_SMALL, - ) - - # Spark - draw_box(ax, 0.3, 0.5, 11.0, 1.5, "", fill=GRAY3, rounded=True, lw=1.5) - ax.text( - 1.0, - 1.7, - "Spark Streaming — Write-Ahead Log (WAL)", - fontsize=FS, - fontweight="bold", - ) - ax.text( - 1.5, - 1.05, - "WAL + checkpointing micro-batchów + idempotent sinks (np. upsert do DB)", - fontsize=FS_SMALL, - ) - - save_fig(fig, "q20_exactly_once.png") - - -# ============================================================ -# 16. Late data strategies (DRAS) -# ============================================================ -def gen_late_data_strategies() -> None: - """Gen late data strategies.""" - fig, ax = plt.subplots(figsize=(9, 5.5)) - ax.set_xlim(0, 12) - ax.set_ylim(0, 7) - ax.set_aspect("auto") - ax.axis("off") - ax.set_title( - "Late Data — 4 strategie (mnemonik DRAS)", - fontsize=FS_TITLE, - fontweight="bold", - pad=10, - ) - - # Setup: window closed, late event arrives - draw_box( - ax, - 0.5, - 5.5, - 4.5, - 1.0, - "Okno [14:00-14:05]\nZAMKNIĘTE o 14:05", - fill=GRAY2, - fontsize=FS, - fontweight="bold", - ) - draw_box( - ax, - 6.0, - 5.5, - 4.5, - 1.0, - "Spóźnione zdarzenie\nevent_time=14:00:03\narrives=14:05:30", - fill="#F8D7DA", - fontsize=FS_SMALL, - fontweight="bold", - ) - draw_arrow(ax, 10.5, 6.0, 5.0, 6.0, lw=2, color="#C62828", style="->") - ax.text( - 7.5, - 5.2, - "LATE!", - fontsize=FS_LABEL, - ha="center", - fontweight="bold", - color="#C62828", - ) - - # 4 strategies - strategies = [ - ("D — Drop", "Odrzuć spóźnione", "/dev/null", GRAY4), - ("R — Recompute", "Przelicz okno ponownie", "poprawne ale kosztowne", GRAY1), - ( - "A — Allowed lateness", - "Czekaj dodatkowy czas\n(np. +2 min)", - "kompromis pamięci", - GRAY2, - ), - ( - "S — Side output", - "Przekieruj do osobnej\nkolejki", - "elastyczne, ręczna analiza", - GRAY3, - ), - ] - for i, (name, desc, tradeoff, color) in enumerate(strategies): - y = 3.8 - i * 1.1 - draw_box(ax, 0.5, y, 2.5, 0.9, name, fill=color, fontsize=FS, fontweight="bold") - ax.text(3.3, y + 0.45, desc, fontsize=FS_SMALL, va="center") - ax.text( - 8.5, - y + 0.45, - tradeoff, - fontsize=FS_SMALL, - va="center", - style="italic", - color="#555", - ) - - save_fig(fig, "q20_late_data_strategies.png") - - -# ============================================================ -# 17. Decision tree — which platform -# ============================================================ -def gen_decision_tree() -> None: - """Gen decision tree.""" - fig, ax = plt.subplots(figsize=(10, 5.5)) - ax.set_xlim(0, 12) - ax.set_ylim(0, 7) - ax.set_aspect("auto") - ax.axis("off") - ax.set_title( - "Drzewo decyzyjne — wybór platformy", - fontsize=FS_TITLE, - fontweight="bold", - pad=10, - ) - - # Root question - draw_box( - ax, - 3.5, - 5.5, - 4.5, - 1.0, - "Latencja < 10ms\nwymagana?", - fill=GRAY2, - fontsize=FS, - fontweight="bold", - ) - - # TAK branch - draw_arrow(ax, 3.5, 5.7, 2.0, 5.0, lw=1.5) - ax.text(2.3, 5.3, "TAK", fontsize=FS, fontweight="bold") - - draw_box( - ax, - 0.3, - 3.5, - 3.5, - 1.0, - "Dane już w Kafce?\nProste transformacje?", - fill=GRAY1, - fontsize=FS, - fontweight="bold", - ) - - # TAK → Kafka Streams - draw_arrow(ax, 0.3, 3.7, -0.1, 3.0, lw=1.5) - ax.text(0.0, 3.3, "TAK", fontsize=FS_SMALL, fontweight="bold") - draw_box( - ax, - -0.3, - 1.8, - 2.5, - 1.0, - "Kafka\nStreams", - fill=GRAY5, - fontsize=FS_LABEL, - fontweight="bold", - ) - - # NIE → Flink - draw_arrow(ax, 3.8, 3.7, 4.5, 3.0, lw=1.5) - ax.text(4.0, 3.3, "NIE\n(złożona logika)", fontsize=FS_SMALL) - draw_box( - ax, - 3.0, - 1.8, - 2.5, - 1.0, - "Apache\nFlink", - fill=GRAY5, - fontsize=FS_LABEL, - fontweight="bold", - ) - - # NIE branch - draw_arrow(ax, 8.0, 5.7, 9.5, 5.0, lw=1.5) - ax.text(8.7, 5.3, "NIE", fontsize=FS, fontweight="bold") - - draw_box( - ax, - 7.5, - 3.5, - 4.2, - 1.0, - "~100ms-1s OK?\nPotrzeba ML / SQL?", - fill=GRAY1, - fontsize=FS, - fontweight="bold", - ) - - # TAK + ML → Spark - draw_arrow(ax, 9.5, 3.5, 9.5, 3.0, lw=1.5) - ax.text(10.0, 3.3, "TAK + ML/SQL", fontsize=FS_SMALL) - draw_box( - ax, - 8.0, - 1.8, - 2.5, - 1.0, - "Spark\nStreaming", - fill=GRAY5, - fontsize=FS_LABEL, - fontweight="bold", - ) - - # TAK + proste → Kafka Streams too - draw_arrow(ax, 7.5, 3.7, 6.5, 3.0, lw=1.5) - ax.text(6.3, 3.3, "proste + TAK", fontsize=FS_SMALL) - draw_box( - ax, - 5.8, - 1.8, - 2.0, - 1.0, - "Kafka\nStreams", - fill=GRAY5, - fontsize=FS, - fontweight="bold", - ) - - # Legend - ax.text( - 6.0, - 0.7, - "Reguła: Kafka Streams = najprostsze (library) | " - "Flink = najpotężniejszy (true streaming) | Spark = ekosystem ML", - fontsize=FS, - ha="center", - bbox={"boxstyle": "round,pad=0.2", "facecolor": GRAY4, "edgecolor": GRAY5}, - ) - - save_fig(fig, "q20_decision_tree.png") - - # ============================================================ # MAIN # ============================================================ diff --git a/python_pkg/praca_magisterska_video/generate_images/generate_q23_diagrams.py b/python_pkg/praca_magisterska_video/generate_images/generate_q23_diagrams.py index a0fa9c2..e553f10 100755 --- a/python_pkg/praca_magisterska_video/generate_images/generate_q23_diagrams.py +++ b/python_pkg/praca_magisterska_video/generate_images/generate_q23_diagrams.py @@ -2,2606 +2,47 @@ """Generate all diagrams for PYTANIE 23: Segmentacja obrazu. A4-compatible, monochrome-friendly (grays + one accent), 300 DPI. +Re-exports all diagram generators from submodules. """ from __future__ import annotations import logging from pathlib import Path -from typing import TYPE_CHECKING +import sys -import matplotlib as mpl +# Ensure sibling modules are importable when run as a script. +sys.path.insert(0, str(Path(__file__).resolve().parent)) -mpl.use("Agg") +from _q23_architectures import generate_fcn, generate_unet +from _q23_common import OUTPUT_DIR +from _q23_diy_unet import generate_diy_unet +from _q23_mean_shift_ncuts import generate_mean_shift, generate_normalized_cuts +from _q23_mnemonics import generate_mnemonics +from _q23_nn_basics import generate_dot_product, generate_relu +from _q23_otsu_watershed import generate_otsu_bimodal, generate_watershed +from _q23_receptive_transformer import generate_receptive_field, generate_transformer +from _q23_region_diy import generate_diy_thresholding, generate_region_growing -from matplotlib import patches -from matplotlib.patches import FancyBboxPatch -import matplotlib.pyplot as plt -import numpy as np - -if TYPE_CHECKING: - from matplotlib.axes import Axes +__all__ = [ + "generate_diy_thresholding", + "generate_diy_unet", + "generate_dot_product", + "generate_fcn", + "generate_mean_shift", + "generate_mnemonics", + "generate_normalized_cuts", + "generate_otsu_bimodal", + "generate_receptive_field", + "generate_region_growing", + "generate_relu", + "generate_transformer", + "generate_unet", + "generate_watershed", +] _logger = logging.getLogger(__name__) -rng = np.random.default_rng(42) - -DPI = 300 -OUTPUT_DIR = str(Path(__file__).resolve().parent / "img") -Path(OUTPUT_DIR).mkdir(parents=True, exist_ok=True) - -# Color palette — monochrome-friendly -BLACK = "#000000" -WHITE = "#FFFFFF" -GRAY1 = "#F5F5F5" -GRAY2 = "#E0E0E0" -GRAY3 = "#BDBDBD" -GRAY4 = "#9E9E9E" -GRAY5 = "#757575" -GRAY6 = "#424242" -ACCENT = "#4A90D9" # single blue accent for highlights -ACCENT_LIGHT = "#B3D4FC" -RED_ACCENT = "#D32F2F" -GREEN_ACCENT = "#388E3C" - -FS = 9 -FS_TITLE = 11 -FS_SMALL = 7 -FS_TINY = 6 - -_RIDGE_X = 5 -_VALLEY2_END = 9 -_DARK_PIXEL_THRESHOLD = 100 -_GRID_LAST_IDX = 3 -_HIGHLIGHT_START = 3 -_HIGHLIGHT_END = 5 -_BRIGHT_THRESHOLD = 170 -_OTSU_THRESHOLD = 128 - - -def _save_figure(name: str) -> None: - """Save current figure and log.""" - plt.tight_layout() - plt.savefig( - str(Path(OUTPUT_DIR) / name), - dpi=DPI, - bbox_inches="tight", - facecolor="white", - ) - plt.close() - _logger.info(" ✓ %s", name) - - -def _render_text_lines( - ax: Axes, - lines: list[tuple[str, int, str, str]], - *, - x_pos: float = 0.5, - start_y: float, - y_step: float = 0.5, - y_empty_step: float = 0.2, -) -> None: - """Render a list of styled text lines on an axis.""" - y = start_y - for txt, size, color, weight in lines: - if txt == "": - y -= y_empty_step - continue - ax.text( - x_pos, y, txt, - fontsize=size, color=color, fontweight=weight, va="top", - ) - y -= y_step - - -def _draw_otsu_variance_panel(ax: Axes) -> None: - """Draw panel 2: within-class variance explanation.""" - ax.set_xlim(0, 10) - ax.set_ylim(0, 10) - ax.axis("off") - ax.set_title("Wariancja wewnątrzklasowa", fontsize=FS_TITLE, fontweight="bold") - - texts = [ - ( - "Wariancja = jak bardzo wartości\nróżnią się od średniej", - FS, - "black", - "normal", - ), - ("", 0, "black", "normal"), - ("Klasa 0 (piksele ≤ T):", FS, ACCENT, "bold"), - (" wartości: 30, 50, 45, 60, 55", FS_SMALL, "black", "normal"), - (" średnia μ₀ = 48", FS_SMALL, "black", "normal"), - (" σ₀² = ((30-48)²+(50-48)²+...)/5 = 108", FS_SMALL, "black", "normal"), - ("", 0, "black", "normal"), - ("Klasa 1 (piksele > T):", FS, RED_ACCENT, "bold"), - (" wartości: 180, 200, 190, 210, 195", FS_SMALL, "black", "normal"), - (" średnia μ₁ = 195", FS_SMALL, "black", "normal"), - (" σ₁² = ((180-195)²+...)/5 = 100", FS_SMALL, "black", "normal"), - ("", 0, "black", "normal"), - ("σ²_wewnątrz = w₀·σ₀² + w₁·σ₁²", FS, BLACK, "bold"), - ("= 0.6·108 + 0.4·100 = 104.8", FS_SMALL, "black", "normal"), - ("", 0, "black", "normal"), - ("Otsu próbuje KAŻDE T: 0,1,...,255", FS_SMALL, GREEN_ACCENT, "bold"), - ("Wybiera T dające MINIMUM σ²_wewnątrz", FS_SMALL, GREEN_ACCENT, "bold"), - ] - _render_text_lines( - ax, texts, - x_pos=0.3, start_y=9.2, y_step=0.55, y_empty_step=0.25, - ) - - -# ============================================================ -# 1. OTSU — Bimodal histogram + within-class variance -# ============================================================ -def generate_otsu_bimodal() -> None: - """Generate otsu bimodal.""" - _fig, axes = plt.subplots(1, 3, figsize=(11, 3.5)) - - # --- Panel 1: Bimodal histogram --- - ax = axes[0] - dark = rng.normal(60, 20, 3000).clip(0, 255) - bright = rng.normal(190, 25, 2000).clip(0, 255) - all_pixels = np.concatenate([dark, bright]) - - counts, _bins, _bars = ax.hist( - all_pixels, bins=64, color=GRAY3, edgecolor=GRAY5, linewidth=0.5 - ) - ax.axvline( - x=128, color=RED_ACCENT, linewidth=2, linestyle="--", label="Próg Otsu T=128" - ) - ax.fill_betweenx([0, max(counts) * 1.1], 0, 128, alpha=0.12, color=ACCENT) - ax.fill_betweenx([0, max(counts) * 1.1], 128, 255, alpha=0.12, color=RED_ACCENT) - ax.text( - 45, - max(counts) * 0.85, - "Klasa 0\n(tło)", - ha="center", - fontsize=FS, - fontweight="bold", - color=ACCENT, - ) - ax.text( - 195, - max(counts) * 0.85, - "Klasa 1\n(obiekt)", - ha="center", - fontsize=FS, - fontweight="bold", - color=RED_ACCENT, - ) - ax.annotate( - "Garb 1", - xy=(60, max(counts) * 0.6), - fontsize=FS_SMALL, - ha="center", - arrowprops={"arrowstyle": "->", "color": GRAY5}, - xytext=(30, max(counts) * 0.45), - ) - ax.annotate( - "Garb 2", - xy=(190, max(counts) * 0.5), - fontsize=FS_SMALL, - ha="center", - arrowprops={"arrowstyle": "->", "color": GRAY5}, - xytext=(220, max(counts) * 0.35), - ) - ax.set_xlabel("Jasność piksela (0-255)", fontsize=FS) - ax.set_ylabel("Liczba pikseli", fontsize=FS) - ax.set_title("Histogram bimodalny", fontsize=FS_TITLE, fontweight="bold") - ax.legend(fontsize=FS_SMALL, loc="upper right") - ax.set_xlim(0, 255) - - # --- Panel 2: Within-class variance explanation --- - _draw_otsu_variance_panel(axes[1]) - - # --- Panel 3: Jednorodność explanation --- - ax = axes[2] - ax.set_xlim(0, 10) - ax.set_ylim(0, 10) - ax.axis("off") - ax.set_title('"Jednorodne" = małe σ²', fontsize=FS_TITLE, fontweight="bold") - - # Draw two clusters - # Good separation - c0 = rng.normal(2, 0.4, 15) - c1 = rng.normal(7, 0.4, 15) - y_pos_0 = rng.uniform(6, 8, 15) - y_pos_1 = rng.uniform(6, 8, 15) - ax.scatter(c0, y_pos_0, c=ACCENT, s=30, zorder=5, label="Klasa 0") - ax.scatter(c1, y_pos_1, c=RED_ACCENT, s=30, zorder=5, label="Klasa 1") - ax.axvline(x=4.5, color=GREEN_ACCENT, linewidth=2, linestyle="--") - ax.text( - 4.5, - 8.8, - "T optymalny", - ha="center", - fontsize=FS_SMALL, - color=GREEN_ACCENT, - fontweight="bold", - ) - ax.text( - 2, 5.3, "σ₀² mała\n(skupione)", ha="center", fontsize=FS_SMALL, color=ACCENT - ) - ax.text( - 7, 5.3, "σ₁² mała\n(skupione)", ha="center", fontsize=FS_SMALL, color=RED_ACCENT - ) - ax.text( - 5, - 4, - "→ σ²_wewnątrz MINIMALNA\n→ klasy JEDNORODNE\n→ dobra segmentacja!", - ha="center", - fontsize=FS, - fontweight="bold", - color=GREEN_ACCENT, - ) - - # Bad separation - c0b = rng.normal(3.5, 1.5, 15) - c1b = rng.normal(6, 1.5, 15) - y_pos_0b = rng.uniform(1, 3, 15) - y_pos_1b = rng.uniform(1, 3, 15) - ax.scatter(c0b, y_pos_0b, c=ACCENT, s=30, marker="x", zorder=5) - ax.scatter(c1b, y_pos_1b, c=RED_ACCENT, s=30, marker="x", zorder=5) - ax.axvline(x=4.5, color=GRAY4, linewidth=1, linestyle=":", ymin=0, ymax=0.35) - ax.text( - 5, - 0.3, - "σ²_wewnątrz DUŻA → klasy mieszają się → zły próg", - ha="center", - fontsize=FS_SMALL, - color=GRAY5, - ) - - ax.legend(fontsize=FS_SMALL, loc="upper left") - - _save_figure("q23_otsu_bimodal.png") - - -def _draw_watershed_result_panel(ax: Axes) -> None: - """Draw panel 3: watershed result with over-segmentation problem.""" - ax.set_xlim(0, 10) - ax.set_ylim(0, 10) - ax.axis("off") - ax.set_title("Krok 3: wynik", fontsize=FS_TITLE, fontweight="bold") - - rect1 = FancyBboxPatch( - (0.5, 6), 3.5, 3.2, - boxstyle="round,pad=0.1", facecolor=ACCENT_LIGHT, - edgecolor=BLACK, linewidth=1, - ) - ax.add_patch(rect1) - ax.text(2.25, 8.8, "Ideał: 2 segmenty", fontsize=FS, ha="center", fontweight="bold") - ax.text(2.25, 7.5, "Segment A Segment B", fontsize=FS_SMALL, ha="center") - ax.text( - 2.25, 6.7, "(po marker-controlled)", - fontsize=FS_SMALL, ha="center", color=GREEN_ACCENT, - ) - - rect2 = FancyBboxPatch( - (5.5, 6), 4, 3.2, - boxstyle="round,pad=0.1", facecolor="#FFCDD2", - edgecolor=BLACK, linewidth=1, - ) - ax.add_patch(rect2) - ax.text( - 7.5, 8.8, "Problem: over-segmentation", - fontsize=FS, ha="center", fontweight="bold", color=RED_ACCENT, - ) - ax.text( - 7.5, 7.8, "47 regionów zamiast 2!", - fontsize=FS_SMALL, ha="center", color=RED_ACCENT, - ) - ax.text(7.5, 7.1, "Każde mini-minimum", fontsize=FS_SMALL, ha="center") - ax.text(7.5, 6.5, '→ osobna „dolina"', fontsize=FS_SMALL, ha="center") - - # Apply marker-controlled solution - rect3 = FancyBboxPatch( - (1, 0.5), 8, 4.5, - boxstyle="round,pad=0.15", facecolor=GRAY1, - edgecolor=GREEN_ACCENT, linewidth=1.5, - ) - ax.add_patch(rect3) - ax.text( - 5, 4.3, "Rozwiązanie: Marker-controlled watershed", - fontsize=FS, ha="center", fontweight="bold", color=GREEN_ACCENT, - ) - ax.text( - 5, 3.4, - '1. Zaznacz ręcznie „seeds" (markery) w każdym obiekcie', - fontsize=FS_SMALL, ha="center", - ) - ax.text( - 5, 2.7, - "2. Zalewaj TYLKO od tych markerów (nie od wszystkich minimów)", - fontsize=FS_SMALL, ha="center", - ) - ax.text( - 5, 2.0, "3. Eliminuje fałszywe doliny z szumu", - fontsize=FS_SMALL, ha="center", - ) - ax.text( - 5, 1.2, "Wynik: tyle segmentów, ile podano markerów", - fontsize=FS_SMALL, ha="center", fontweight="bold", - ) - - -# ============================================================ -# 2. WATERSHED — Topographic flooding (not ASCII!) -# ============================================================ -def generate_watershed() -> None: - """Generate watershed.""" - _fig, axes = plt.subplots(1, 3, figsize=(11, 3.8)) - - # --- Panel 1: Image as topographic surface --- - ax = axes[0] - x = np.linspace(0, 10, 200) - # Create a surface with two valleys and a ridge - surface = ( - 3 * np.exp(-((x - 3) ** 2) / 1.5) - + 4 * np.exp(-((x - 7) ** 2) / 1.2) - + 0.5 * np.sin(x * 2) - + 1 - ) - # Invert: valleys at objects (dark), peaks at boundaries (bright) - surface_inv = 6 - surface + 1 - - ax.fill_between(x, 0, surface_inv, color=GRAY2, alpha=0.7) - ax.plot(x, surface_inv, color=BLACK, linewidth=1.5) - - # Mark valleys - ax.annotate( - "Dolina 1\n(obiekt A)", - xy=(3, surface_inv[60]), - fontsize=FS_SMALL, - ha="center", - va="bottom", - arrowprops={"arrowstyle": "->", "color": ACCENT}, - xytext=(1.5, 5.5), - ) - ax.annotate( - "Dolina 2\n(obiekt B)", - xy=(7, surface_inv[140]), - fontsize=FS_SMALL, - ha="center", - va="bottom", - arrowprops={"arrowstyle": "->", "color": RED_ACCENT}, - xytext=(8.5, 5.5), - ) - # Mark ridge - ax.annotate( - "Grań\n(granica)", - xy=(5, surface_inv[100]), - fontsize=FS_SMALL, - ha="center", - va="bottom", - arrowprops={"arrowstyle": "->", "color": GREEN_ACCENT}, - xytext=(5, 6.5), - ) - - ax.set_xlabel("Pozycja piksela", fontsize=FS) - ax.set_ylabel("Jasność (= wysokość)", fontsize=FS) - ax.set_title("Krok 1: obraz → teren", fontsize=FS_TITLE, fontweight="bold") - ax.set_ylim(0, 7) - - # --- Panel 2: Flooding --- - ax = axes[1] - ax.fill_between(x, 0, surface_inv, color=GRAY2, alpha=0.7) - ax.plot(x, surface_inv, color=BLACK, linewidth=1.5) - - # Water level - water_level = 3.2 - - # Fill water in valley 1 - x_v1 = x[(x > 1) & (x < _RIDGE_X)] - s_v1 = surface_inv[(x > 1) & (x < _RIDGE_X)] - ax.fill_between( - x_v1, s_v1, water_level, where=s_v1 < water_level, color=ACCENT_LIGHT, alpha=0.6 - ) - # Fill water in valley 2 - x_v2 = x[(x > _RIDGE_X) & (x < _VALLEY2_END)] - s_v2 = surface_inv[(x > _RIDGE_X) & (x < _VALLEY2_END)] - ax.fill_between( - x_v2, s_v2, water_level, where=s_v2 < water_level, color="#FFCDD2", alpha=0.6 - ) - - ax.axhline(y=water_level, color=ACCENT, linewidth=1, linestyle="--", alpha=0.5) - ax.text(3, 2.5, "Woda A", fontsize=FS, ha="center", color=ACCENT, fontweight="bold") - ax.text( - 7, 2.2, "Woda B", fontsize=FS, ha="center", color=RED_ACCENT, fontweight="bold" - ) - ax.annotate( - "Tu się spotkają!\n→ GRANICA", - xy=(5, surface_inv[100]), - fontsize=FS_SMALL, - ha="center", - color=GREEN_ACCENT, - fontweight="bold", - arrowprops={"arrowstyle": "->", "color": GREEN_ACCENT}, - xytext=(5, 6.2), - ) - - ax.set_xlabel("Pozycja piksela", fontsize=FS) - ax.set_title("Krok 2: zalewanie", fontsize=FS_TITLE, fontweight="bold") - ax.set_ylim(0, 7) - - # --- Panel 3: Result with problem --- - _draw_watershed_result_panel(axes[2]) - - _save_figure("q23_watershed.png") - - -# ============================================================ -# 3. MEAN SHIFT — Kernel, density, feature space -# ============================================================ -def generate_mean_shift() -> None: - """Generate mean shift.""" - _fig, axes = plt.subplots(1, 3, figsize=(11, 4)) - - # --- Panel 1: Feature space concept --- - ax = axes[0] - # Three clusters in 2D feature space (brightness, x-position) - c1x = rng.normal(2, 0.5, 40) - c1y = rng.normal(2, 0.5, 40) - c2x = rng.normal(6, 0.6, 35) - c2y = rng.normal(7, 0.5, 35) - c3x = rng.normal(8, 0.4, 25) - c3y = rng.normal(3, 0.6, 25) - - ax.scatter(c1x, c1y, c=GRAY4, s=15, alpha=0.7, zorder=3) - ax.scatter(c2x, c2y, c=GRAY4, s=15, alpha=0.7, zorder=3) - ax.scatter(c3x, c3y, c=GRAY4, s=15, alpha=0.7, zorder=3) - - # Label peaks - ax.scatter([2], [2], c=RED_ACCENT, s=80, marker="*", zorder=5, label="Max gęstości") - ax.scatter([6], [7], c=RED_ACCENT, s=80, marker="*", zorder=5) - ax.scatter([8], [3], c=RED_ACCENT, s=80, marker="*", zorder=5) - - ax.set_xlabel("Cecha 1: jasność", fontsize=FS) - ax.set_ylabel("Cecha 2: pozycja x", fontsize=FS) - ax.set_title("Przestrzeń cech", fontsize=FS_TITLE, fontweight="bold") - for lx, ly, ltxt in [ - (2, 0.3, "Klaster 1\n(ciemne, lewo)"), - (6, 5.3, "Klaster 2\n(jasne, prawo)"), - (8, 1.3, "Klaster 3\n(jasne, dół)"), - ]: - ax.text(lx, ly, ltxt, ha="center", fontsize=FS_TINY, color=GRAY6) - ax.legend(fontsize=FS_SMALL, loc="upper left") - - # --- Panel 2: Kernel/window moving --- - ax = axes[1] - ax.scatter(c1x, c1y, c=ACCENT_LIGHT, s=15, alpha=0.7, zorder=3) - ax.scatter(c2x, c2y, c=GRAY3, s=15, alpha=0.7, zorder=3) - ax.scatter(c3x, c3y, c=GRAY3, s=15, alpha=0.7, zorder=3) - - # Show kernel movement - path_x = [4.5, 3.8, 3.0, 2.3, 2.05] - path_y = [4.0, 3.3, 2.7, 2.2, 2.03] - - for i, (px, py) in enumerate(zip(path_x, path_y, strict=False)): - alpha = 0.3 + 0.15 * i - circle = plt.Circle( - (px, py), - 1.2, - fill=False, - edgecolor=ACCENT, - linewidth=1.5, - linestyle="--" if i < len(path_x) - 1 else "-", - alpha=alpha, - ) - ax.add_patch(circle) - if i < len(path_x) - 1: - ax.annotate( - "", - xy=(path_x[i + 1], path_y[i + 1]), - xytext=(px, py), - arrowprops={"arrowstyle": "->", "color": RED_ACCENT, "lw": 1.5}, - ) - - ax.scatter([path_x[0]], [path_y[0]], c=ACCENT, s=50, marker="o", zorder=5) - ax.scatter([path_x[-1]], [path_y[-1]], c=RED_ACCENT, s=80, marker="*", zorder=5) - - ax.text( - 4.5, 5.2, "Start: losowy\npiksel", fontsize=FS_SMALL, ha="center", color=ACCENT - ) - ax.text( - 2.05, - 0.5, - "Koniec: max\ngęstości", - fontsize=FS_SMALL, - ha="center", - color=RED_ACCENT, - fontweight="bold", - ) - ax.text( - 7, - 8, - "Okno (jądro)\nprzesuwa się\ndo skupiska", - fontsize=FS_SMALL, - ha="center", - color=GRAY6, - bbox={"boxstyle": "round", "facecolor": GRAY1, "edgecolor": GRAY3}, - ) - - ax.set_xlabel("Cecha 1", fontsize=FS) - ax.set_ylabel("Cecha 2", fontsize=FS) - ax.set_title("Jądro → max gęstości", fontsize=FS_TITLE, fontweight="bold") - ax.set_xlim(0, 10) - ax.set_ylim(0, 9) - - # --- Panel 3: Why no K parameter --- - ax = axes[2] - ax.set_xlim(0, 10) - ax.set_ylim(0, 10) - ax.axis("off") - ax.set_title("Dlaczego bez K?", fontsize=FS_TITLE, fontweight="bold") - - lines = [ - ("K-means wymaga:", FS, RED_ACCENT, "bold"), - (' „Podaj K=3 klastry"', FS_SMALL, "black", "normal"), - (" Problem: skąd wiesz ile klastrów?", FS_SMALL, GRAY5, "normal"), - ("", 0, "", ""), - ("Mean Shift NIE wymaga K:", FS, GREEN_ACCENT, "bold"), - (" Każdy piksel startuje → toczy się", FS_SMALL, "black", "normal"), - (" → trafia do najbliższego szczytu", FS_SMALL, "black", "normal"), - (" → ile szczytów = tyle segmentów", FS_SMALL, "black", "normal"), - (" → automatycznie!", FS_SMALL, GREEN_ACCENT, "bold"), - ("", 0, "", ""), - ("Parametr: bandwidth (szerokość okna)", FS, "black", "bold"), - (" Duże okno → mało segmentów", FS_SMALL, "black", "normal"), - (" Małe okno → dużo segmentów", FS_SMALL, "black", "normal"), - ("", 0, "", ""), - ("Okno = jądro (kernel):", FS, "black", "bold"), - (" Koło o promieniu h wokół punktu.", FS_SMALL, "black", "normal"), - (" Oblicz średnią pikseli W oknie.", FS_SMALL, "black", "normal"), - (" Przesuń okno na tę średnią.", FS_SMALL, "black", "normal"), - (" Powtórz aż się zatrzyma.", FS_SMALL, "black", "normal"), - ] - _render_text_lines(ax, lines, start_y=9.0) - - _save_figure("q23_mean_shift.png") - - -def _draw_ncuts_pixel_grid( - ax: Axes, pixel_vals: np.ndarray, -) -> None: - """Draw 4x4 pixel grid with value labels and edge weights.""" - for i in range(4): - for j in range(4): - v = pixel_vals[i, j] - gray_val = v / 255.0 - str(gray_val) - rect = patches.Rectangle( - (j - 0.4, 3 - i - 0.4), - 0.8, 0.8, - facecolor=(gray_val, gray_val, gray_val), - edgecolor=BLACK, linewidth=0.8, - ) - ax.add_patch(rect) - text_color = ( - "white" if v < _DARK_PIXEL_THRESHOLD else "black" - ) - ax.text( - j, 3 - i, str(v), - ha="center", va="center", fontsize=FS_SMALL, - color=text_color, fontweight="bold", - ) - - -def _draw_ncuts_edges( - ax: Axes, pixel_vals: np.ndarray, -) -> None: - """Draw weighted edges between adjacent pixels.""" - for i in range(4): - for j in range(4): - if j < _GRID_LAST_IDX: - similarity = max( - 0, - 1 - abs(pixel_vals[i, j] - pixel_vals[i, j + 1]) - / 255, - ) - lw = similarity * 2.5 + 0.3 - alpha = similarity * 0.8 + 0.2 - ax.plot( - [j + 0.4, j + 0.6], [3 - i, 3 - i], - color=GRAY5, linewidth=lw, alpha=alpha, - ) - if i < _GRID_LAST_IDX: - similarity = max( - 0, - 1 - abs(pixel_vals[i, j] - pixel_vals[i + 1, j]) - / 255, - ) - lw = similarity * 2.5 + 0.3 - alpha = similarity * 0.8 + 0.2 - ax.plot( - [j, j], [3 - i - 0.4, 3 - i - 0.6], - color=GRAY5, linewidth=lw, alpha=alpha, - ) - - -# ============================================================ -# 4. NORMALIZED CUTS — Graph cut visualization -# ============================================================ -def generate_normalized_cuts() -> None: - """Generate normalized cuts.""" - _fig, axes = plt.subplots(1, 3, figsize=(11, 4)) - - # --- Panel 1: Image as graph --- - ax = axes[0] - ax.set_xlim(-0.5, 4.5) - ax.set_ylim(-0.5, 4.5) - ax.set_aspect("equal") - ax.set_title("Obraz → graf", fontsize=FS_TITLE, fontweight="bold") - - pixel_vals = np.array( - [ - [30, 35, 180, 190], - [40, 30, 185, 200], - [170, 180, 40, 35], - [190, 175, 30, 45], - ] - ) - _draw_ncuts_pixel_grid(ax, pixel_vals) - _draw_ncuts_edges(ax, pixel_vals) - - ax.text( - 2, - -0.8, - "Grube linie = duże podobieństwo\n(silna krawędź grafu)", - ha="center", - fontsize=FS_TINY, - color=GRAY5, - ) - ax.axis("off") - - # --- Panel 2: Cut concept --- - ax = axes[1] - ax.set_xlim(0, 10) - ax.set_ylim(0, 10) - ax.axis("off") - ax.set_title("Cięcie grafu (graph cut)", fontsize=FS_TITLE, fontweight="bold") - - # Draw two groups of nodes - # Group A (dark pixels) - positions_a = [(2, 7), (3, 8), (2, 5), (3, 6)] - positions_b = [(7, 7), (8, 8), (7, 5), (8, 6)] - - # Intra-group edges (thick = similar) - for i, (x1, y1) in enumerate(positions_a): - for x2, y2 in positions_a[i + 1 :]: - ax.plot([x1, x2], [y1, y2], color=ACCENT, linewidth=2, alpha=0.5) - for i, (x1, y1) in enumerate(positions_b): - for x2, y2 in positions_b[i + 1 :]: - ax.plot([x1, x2], [y1, y2], color=RED_ACCENT, linewidth=2, alpha=0.5) - - # Inter-group edges (thin = dissimilar) — these get cut - cut_edges = [((3, 8), (7, 7)), ((3, 6), (7, 5)), ((2, 5), (7, 5))] - for (x1, y1), (x2, y2) in cut_edges: - ax.plot([x1, x2], [y1, y2], color=GRAY4, linewidth=0.8, linestyle="--") - - # Draw nodes - for x, y in positions_a: - ax.scatter(x, y, c=ACCENT, s=120, zorder=5, edgecolors=BLACK, linewidth=0.8) - for x, y in positions_b: - ax.scatter(x, y, c="#FFCDD2", s=120, zorder=5, edgecolors=BLACK, linewidth=0.8) - - # Cut line - ax.plot( - [5, 5], [3.5, 9.5], color=RED_ACCENT, linewidth=2.5, linestyle="-", zorder=4 - ) - ax.text( - 5, 9.8, "CIĘCIE", ha="center", fontsize=FS, fontweight="bold", color=RED_ACCENT - ) - - ax.text( - 2.5, - 3.8, - "Segment A\n(ciemne piksele)", - ha="center", - fontsize=FS_SMALL, - color=ACCENT, - ) - ax.text( - 7.5, - 3.8, - "Segment B\n(jasne piksele)", - ha="center", - fontsize=FS_SMALL, - color=RED_ACCENT, - ) - - # Formula - ax.text( - 5, - 1.8, - "Ncut(A,B) = cut(A,B)/assoc(A,V)\n + cut(A,B)/assoc(B,V)", - ha="center", - fontsize=FS_SMALL, - fontweight="bold", - bbox={"boxstyle": "round", "facecolor": GRAY1, "edgecolor": GRAY3}, - ) - ax.text( - 5, - 0.5, - "Minimalizuj Ncut → tnij SŁABE krawędzie\nzachowuj SILNE (wewnątrz grupy)", - ha="center", - fontsize=FS_TINY, - color=GRAY5, - ) - - # --- Panel 3: Algorithm summary --- - ax = axes[2] - ax.set_xlim(0, 10) - ax.set_ylim(0, 10) - ax.axis("off") - ax.set_title("Algorytm Normalized Cuts", fontsize=FS_TITLE, fontweight="bold") - - steps = [ - ( - "1. Zbuduj graf", - "Piksele = węzły\nKrawędzie = podobieństwo" - " sąsiadów\n(kolor, jasność, odległość)", - ), - ( - "2. Macierz podobieństwa W", - "W[i,j] = exp(-|kolori - kolorj|² / σ²)" - "\n→ im podobniejsze, tym wyższa waga", - ), - ("3. Macierz stopni D", "D[i,i] = Σ W[i,j]\n(suma wszystkich wag z węzła i)"), - ("4. Rozwiąż problem własny", "(D-W)·y = λ·D·y\n→ drugi najm. wektor własny y"), - ("5. Podziel wg y", "y[i] > 0 → segment A\ny[i] ≤ 0 → segment B"), - ] - - y = 9.5 - for title, desc in steps: - ax.text(0.5, y, title, fontsize=FS, fontweight="bold", va="top") - y -= 0.4 - ax.text(0.8, y, desc, fontsize=FS_TINY, va="top", color=GRAY6) - y -= 1.2 - - ax.text( - 5, - 0.3, - "Złożoność: O(n³) — wymaga eigen decomposition!", - ha="center", - fontsize=FS_SMALL, - fontweight="bold", - color=RED_ACCENT, - ) - - _save_figure("q23_normalized_cuts.png") - - -# ============================================================ -# 5. RELU — Function plot -# ============================================================ -def generate_relu() -> None: - """Generate relu.""" - _fig, axes = plt.subplots(1, 2, figsize=(8, 3.5)) - - # --- Panel 1: ReLU plot --- - ax = axes[0] - x = np.linspace(-5, 5, 200) - relu = np.maximum(0, x) - ax.plot(x, relu, color=ACCENT, linewidth=2.5, label="ReLU(x) = max(0, x)") - ax.axhline(y=0, color=GRAY3, linewidth=0.5) - ax.axvline(x=0, color=GRAY3, linewidth=0.5) - ax.fill_between(x[x < 0], 0, 0, color=RED_ACCENT, alpha=0.1) - ax.fill_between(x[x >= 0], 0, relu[x >= 0], color=ACCENT, alpha=0.1) - - # Annotations - ax.annotate( - 'x < 0 → output = 0\n(neuron „wyłączony")', - xy=(-3, 0), - fontsize=FS_SMALL, - ha="center", - va="bottom", - color=RED_ACCENT, - arrowprops={"arrowstyle": "->", "color": RED_ACCENT}, - xytext=(-3, 2), - ) - ax.annotate( - 'x ≥ 0 → output = x\n(neuron „włączony")', - xy=(3, 3), - fontsize=FS_SMALL, - ha="center", - va="bottom", - color=ACCENT, - arrowprops={"arrowstyle": "->", "color": ACCENT}, - xytext=(3, 4.5), - ) - ax.scatter([0], [0], c=BLACK, s=40, zorder=5) - ax.text(0.3, -0.5, "(0,0)", fontsize=FS_SMALL, color=GRAY5) - ax.set_xlabel("x (wejście neuronu)", fontsize=FS) - ax.set_ylabel("ReLU(x)", fontsize=FS) - ax.set_title("ReLU — Rectified Linear Unit", fontsize=FS_TITLE, fontweight="bold") - ax.legend(fontsize=FS_SMALL, loc="upper left") - ax.set_ylim(-1, 6) - ax.grid(visible=True, alpha=0.2) - - # --- Panel 2: Why ReLU --- - ax = axes[1] - ax.set_xlim(0, 10) - ax.set_ylim(0, 10) - ax.axis("off") - ax.set_title("Dlaczego ReLU?", fontsize=FS_TITLE, fontweight="bold") - - y = 9.0 - lines = [ - ("Neuron oblicza:", FS, BLACK, "bold"), - (" z = w₁·x₁ + w₂·x₂ + ... + bias", FS_SMALL, BLACK, "normal"), - (" output = ReLU(z) = max(0, z)", FS_SMALL, ACCENT, "bold"), - ("", 0, "", ""), - ("Przykład:", FS, BLACK, "bold"), - (" wagi: w₁=0.5, w₂=-0.3, bias=0.1", FS_SMALL, BLACK, "normal"), - (" wejścia: x₁=2.0, x₂=4.0", FS_SMALL, BLACK, "normal"), - (" z = 0.5·2 + (-0.3)·4 + 0.1 = -0.1", FS_SMALL, BLACK, "normal"), - (" ReLU(-0.1) = max(0, -0.1) = 0", FS_SMALL, RED_ACCENT, "bold"), - (" → neuron milczy (wejście nieistotne)", FS_SMALL, GRAY5, "normal"), - ("", 0, "", ""), - ("Gdyby z = 2.3:", FS, BLACK, "bold"), - (" ReLU(2.3) = max(0, 2.3) = 2.3", FS_SMALL, GREEN_ACCENT, "bold"), - (" → neuron aktywny! Przekazuje sygnał", FS_SMALL, GRAY5, "normal"), - ("", 0, "", ""), - ("Szybsza niż sigmoid/tanh", FS_SMALL, GRAY5, "normal"), - ("(brak exp() → szybkie obliczenia)", FS_SMALL, GRAY5, "normal"), - ] - for txt, size, color, weight in lines: - if txt == "": - y -= 0.2 - continue - ax.text(0.5, y, txt, fontsize=size, color=color, fontweight=weight, va="top") - y -= 0.5 - - _save_figure("q23_relu.png") - - -# ============================================================ -# 6. DOT PRODUCT — Iloczyn skalarny visual -# ============================================================ -def generate_dot_product() -> None: - """Generate dot product.""" - _fig, axes = plt.subplots(1, 3, figsize=(11, 3.5)) - - # --- Panel 1: Concept --- - ax = axes[0] - ax.set_xlim(0, 10) - ax.set_ylim(0, 10) - ax.axis("off") - ax.set_title( - "Iloczyn skalarny\n(dot product)", fontsize=FS_TITLE, fontweight="bold" - ) - - y = 8.5 - lines = [ - ("Dwa wektory (listy liczb) → JEDNA liczba", FS, BLACK, "bold"), - ("", 0, "", ""), - ("a = [a₁, a₂, a₃] b = [b₁, b₂, b₃]", FS, ACCENT, "normal"), - ("", 0, "", ""), - ("a · b = a₁·b₁ + a₂·b₂ + a₃·b₃", FS, BLACK, "bold"), - ("", 0, "", ""), - ("Przykład:", FS, BLACK, "bold"), - ("a = [1, 3, -2] b = [4, -1, 5]", FS_SMALL, BLACK, "normal"), - ("a·b = 1·4 + 3·(-1) + (-2)·5", FS_SMALL, BLACK, "normal"), - (" = 4 + (-3) + (-10) = -9", FS_SMALL, RED_ACCENT, "bold"), - ("", 0, "", ""), - ( - 'Duży wynik → wektory „podobne" (w tym samym kierunku)', - FS_SMALL, - GREEN_ACCENT, - "normal", - ), - ('Mały/ujemny → wektory „różne"', FS_SMALL, RED_ACCENT, "normal"), - ] - for txt, size, color, weight in lines: - if txt == "": - y -= 0.25 - continue - ax.text(0.5, y, txt, fontsize=size, color=color, fontweight=weight, va="top") - y -= 0.55 - - # --- Panel 2: Convolution as dot product --- - ax = axes[1] - ax.set_xlim(-0.5, 5.5) - ax.set_ylim(-0.5, 5.5) - ax.set_aspect("equal") - ax.set_title( - "Konwolucja = iloczyn skalarny\nfiltra x fragment obrazu", - fontsize=FS_TITLE, - fontweight="bold", - ) - - # Filter 3x3 - filter_vals = [[-1, 0, 1], [-1, 0, 1], [-1, 0, 1]] - for i in range(3): - for j in range(3): - rect = patches.Rectangle( - (j - 0.4, 4 - i - 0.4), - 0.8, - 0.8, - facecolor=ACCENT_LIGHT, - edgecolor=BLACK, - linewidth=0.8, - ) - ax.add_patch(rect) - ax.text( - j, - 4 - i, - str(filter_vals[i][j]), - ha="center", - va="center", - fontsize=FS, - fontweight="bold", - ) - - ax.text(1, 1.5, "Filtr", ha="center", fontsize=FS, fontweight="bold", color=ACCENT) - - # Image patch - img_vals = [[50, 50, 200], [50, 50, 200], [50, 50, 200]] - for i in range(3): - for j in range(3): - rect = patches.Rectangle( - (j + 2.6, 4 - i - 0.4), - 0.8, - 0.8, - facecolor=GRAY2, - edgecolor=BLACK, - linewidth=0.8, - ) - ax.add_patch(rect) - ax.text( - j + 3, - 4 - i, - str(img_vals[i][j]), - ha="center", - va="center", - fontsize=FS, - fontweight="bold", - ) - - ax.text( - 4, - 1.5, - "Fragment\nobrazu", - ha="center", - fontsize=FS, - fontweight="bold", - color=GRAY5, - ) - - ax.text( - 2.5, - 0.5, - "(-1)·50 + 0·50 + 1·200 +\n" - "(-1)·50 + 0·50 + 1·200 +\n" - "(-1)·50 + 0·50 + 1·200\n= 450 (krawędź!)", - ha="center", - fontsize=FS_TINY, - fontweight="bold", - bbox={"boxstyle": "round", "facecolor": GRAY1, "edgecolor": GREEN_ACCENT}, - ) - - ax.axis("off") - - # --- Panel 3: Vector visualization --- - ax = axes[2] - # Draw two vectors - ax.quiver( - 0, - 0, - 3, - 4, - angles="xy", - scale_units="xy", - scale=1, - color=ACCENT, - width=0.025, - label="a = [3, 4]", - ) - ax.quiver( - 0, - 0, - 4, - 1, - angles="xy", - scale_units="xy", - scale=1, - color=RED_ACCENT, - width=0.025, - label="b = [4, 1]", - ) - - # Show angle - theta = np.linspace(np.arctan2(1, 4), np.arctan2(4, 3), 30) - r = 1.5 - ax.plot(r * np.cos(theta), r * np.sin(theta), color=GREEN_ACCENT, linewidth=1.5) - ax.text(1.8, 1.3, "θ", fontsize=FS, color=GREEN_ACCENT, fontweight="bold") - - ax.text(3.2, 4.2, "a", fontsize=FS, color=ACCENT, fontweight="bold") - ax.text(4.2, 1.2, "b", fontsize=FS, color=RED_ACCENT, fontweight="bold") - - ax.text( - 2.5, - -1.0, - "a · b = |a|·|b|·cos(θ)\n= 3·4 + 4·1 = 16", - ha="center", - fontsize=FS_SMALL, - fontweight="bold", - bbox={"boxstyle": "round", "facecolor": GRAY1, "edgecolor": GRAY3}, - ) - ax.text( - 2.5, - -2.0, - 'Mały kąt θ → duży dot product\n= wektory „zgadają się"', - ha="center", - fontsize=FS_TINY, - color=GRAY5, - ) - - ax.set_xlim(-0.5, 5.5) - ax.set_ylim(-2.5, 5.5) - ax.set_aspect("equal") - ax.grid(visible=True, alpha=0.2) - ax.legend(fontsize=FS_SMALL, loc="upper left") - ax.set_title("Geometrycznie: kąt", fontsize=FS_TITLE, fontweight="bold") - - _save_figure("q23_dot_product.png") - - -# ============================================================ -# 7. FCN — FC vs Conv 1x1, skip connections -# ============================================================ -def generate_fcn() -> None: - """Generate fcn.""" - _fig, axes = plt.subplots(2, 1, figsize=(10, 7)) - - # --- Panel 1: FC vs Conv 1x1 --- - ax = axes[0] - ax.set_xlim(0, 20) - ax.set_ylim(0, 6) - ax.axis("off") - ax.set_title( - "FC (Fully Connected) vs Conv 1x1", fontsize=FS_TITLE, fontweight="bold" - ) - - # Classic CNN with FC - layer_info_fc = [ - (1.5, "Obraz\n224x224x3", 2.2, GRAY2), - (4.5, "Conv+Pool\n112x112x64", 1.8, GRAY2), - (7.5, "Conv+Pool\n7x7x512", 1.0, GRAY2), - (10, "Flatten\n25088", 0.5, ACCENT_LIGHT), - (12, "FC\n4096", 0.5, ACCENT_LIGHT), - (14, "FC\n1000", 0.3, ACCENT_LIGHT), - (16, '"Kot"', 0.3, "#FFCDD2"), - ] - - y_fc = 4.5 - for i, (x, label, w, color) in enumerate(layer_info_fc): - rect = FancyBboxPatch( - (x - w / 2, y_fc - 0.6), - w, - 1.2, - boxstyle="round,pad=0.05", - facecolor=color, - edgecolor=BLACK, - linewidth=0.8, - ) - ax.add_patch(rect) - ax.text(x, y_fc, label, ha="center", va="center", fontsize=FS_TINY) - if i < len(layer_info_fc) - 1: - next_x = layer_info_fc[i + 1][0] - ax.annotate( - "", - xy=(next_x - layer_info_fc[i + 1][2] / 2, y_fc), - xytext=(x + w / 2, y_fc), - arrowprops={"arrowstyle": "->", "color": GRAY5, "lw": 1}, - ) - - ax.text( - 0.3, y_fc, "CNN:", fontsize=FS, fontweight="bold", color=RED_ACCENT, va="center" - ) - ax.text( - 12, - y_fc + 1, - "PROBLEM: FC wymaga\nSTAŁEGO rozmiaru\n(np. 224x224)", - ha="center", - fontsize=FS_SMALL, - color=RED_ACCENT, - fontweight="bold", - bbox={ - "boxstyle": "round", - "facecolor": "#FFCDD2", - "edgecolor": RED_ACCENT, - "alpha": 0.3, - }, - ) - - # FCN with Conv 1x1 - layer_info_fcn = [ - (1.5, "Obraz\nHxWx3", 2.2, GRAY2), - (4.5, "Conv+Pool\nH/2 x W/2\nx64", 1.8, GRAY2), - (7.5, "Conv+Pool\nH/32 x W/32\nx512", 1.0, GRAY2), - (10.5, "Conv 1x1\nH/32 x W/32\nxC", 0.8, "#C8E6C9"), - (13.5, "Upsample\nHxWxC", 1.8, "#C8E6C9"), - (16.5, "Mapa\nsegmentacji", 1.5, "#C8E6C9"), - ] - - y_fcn = 1.5 - for i, (x, label, w, color) in enumerate(layer_info_fcn): - rect = FancyBboxPatch( - (x - w / 2, y_fcn - 0.7), - w, - 1.4, - boxstyle="round,pad=0.05", - facecolor=color, - edgecolor=BLACK, - linewidth=0.8, - ) - ax.add_patch(rect) - ax.text(x, y_fcn, label, ha="center", va="center", fontsize=FS_TINY) - if i < len(layer_info_fcn) - 1: - next_x = layer_info_fcn[i + 1][0] - ax.annotate( - "", - xy=(next_x - layer_info_fcn[i + 1][2] / 2, y_fcn), - xytext=(x + w / 2, y_fcn), - arrowprops={"arrowstyle": "->", "color": GRAY5, "lw": 1}, - ) - - ax.text( - 0.3, - y_fcn, - "FCN:", - fontsize=FS, - fontweight="bold", - color=GREEN_ACCENT, - va="center", - ) - ax.text( - 10.5, - y_fcn + 1.2, - "Conv 1x1:\nkażdy piksel\nosobno x wagi\n(jak FC ale\nzachowuje HxW)", - ha="center", - fontsize=FS_TINY, - color=GREEN_ACCENT, - bbox={ - "boxstyle": "round", - "facecolor": "#C8E6C9", - "edgecolor": GREEN_ACCENT, - "alpha": 0.3, - }, - ) - - # --- Panel 2: What FC and Conv do --- - ax = axes[1] - ax.set_xlim(0, 20) - ax.set_ylim(0, 6) - ax.axis("off") - ax.set_title( - "Co robi warstwa FC? Co robi konwolucja?", fontsize=FS_TITLE, fontweight="bold" - ) - - # FC explanation - rect = FancyBboxPatch( - (0.3, 3.2), - 9, - 2.5, - boxstyle="round,pad=0.15", - facecolor=ACCENT_LIGHT, - edgecolor=ACCENT, - linewidth=1, - ) - ax.add_patch(rect) - ax.text( - 4.8, 5.2, "Fully Connected (FC)", fontsize=FS, fontweight="bold", ha="center" - ) - ax.text( - 4.8, - 4.5, - "KAŻDY neuron połączony z KAŻDYM wejściem\n" - "25 088 wejść x 4 096 neuronów = ~103 MLN wag!\n" - "Traci informację GDZIE (przestrzenną)\n" - "Wymaga STAŁEGO rozmiaru wejścia", - fontsize=FS_TINY, - ha="center", - va="top", - ) - - # Conv explanation - rect = FancyBboxPatch( - (10.3, 3.2), - 9, - 2.5, - boxstyle="round,pad=0.15", - facecolor="#C8E6C9", - edgecolor=GREEN_ACCENT, - linewidth=1, - ) - ax.add_patch(rect) - ax.text(14.8, 5.2, "Konwolucja (Conv)", fontsize=FS, fontweight="bold", ha="center") - ax.text( - 14.8, - 4.5, - 'Filtr (np. 3x3) „jedzie" po obrazie\n' - "Te same wagi dla KAŻDEJ pozycji\n" - "Zachowuje informację GDZIE\n" - "Akceptuje DOWOLNY rozmiar wejścia", - fontsize=FS_TINY, - ha="center", - va="top", - ) - - # Conv 1x1 explanation - rect = FancyBboxPatch( - (3, 0.3), - 14, - 2.2, - boxstyle="round,pad=0.15", - facecolor=GRAY1, - edgecolor=BLACK, - linewidth=1, - ) - ax.add_patch(rect) - ax.text( - 10, - 2.1, - 'Conv 1x1 = „FC per piksel"', - fontsize=FS, - fontweight="bold", - ha="center", - ) - ax.text( - 10, - 1.5, - "Filtr 1x1: patrzy na JEDEN piksel, ale WSZYSTKIE kanały (512→C klas)\n" - "Działa jak FC ale zachowuje mapę HxW → każdy piksel osobno klasyfikowany\n" - "FCN: zamień FC na Conv1x1 → koniec z wymogiem stałego rozmiaru!", - fontsize=FS_TINY, - ha="center", - va="top", - ) - - _save_figure("q23_fc_vs_conv1x1.png") - - -# ============================================================ -# 8. U-NET ARCHITECTURE — Proper U-shaped diagram -# ============================================================ -def generate_unet() -> None: - """Generate unet.""" - _fig, ax = plt.subplots(1, 1, figsize=(10, 6)) - ax.set_xlim(-1, 21) - ax.set_ylim(-1, 12) - ax.axis("off") - ax.set_title( - "U-Net: architektura w kształcie litery U", - fontsize=FS_TITLE + 1, - fontweight="bold", - ) - - # Encoder layers (going DOWN-LEFT) - encoder_layers = [ - (2, 10, 2.5, 1.5, "572x572x1\n(wejście)", 64), - (2, 7.5, 2.2, 1.3, "284x284\nx64", 64), - (2, 5, 1.8, 1.1, "140x140\nx128", 128), - (2, 2.5, 1.5, 1.0, "68x68\nx256", 256), - ] - - # Bottleneck - bottleneck = (8, 0.5, 2.5, 1.2, "32x32x512\n(bottleneck)", 512) - - # Decoder layers (going UP-RIGHT) - decoder_layers = [ - (14, 2.5, 1.5, 1.0, "68x68\nx256", 256), - (14, 5, 1.8, 1.1, "140x140\nx128", 128), - (14, 7.5, 2.2, 1.3, "284x284\nx64", 64), - (14, 10, 2.5, 1.5, "572x572xC\n(mapa seg.)", "C"), - ] - - def draw_block( - ax: Axes, - x: float, - y: float, - w: float, - h: float, - label: str, - color: str, - ) -> None: - """Draw block.""" - rect = FancyBboxPatch( - (x - w / 2, y - h / 2), - w, - h, - boxstyle="round,pad=0.05", - facecolor=color, - edgecolor=BLACK, - linewidth=1.2, - ) - ax.add_patch(rect) - ax.text(x, y, label, ha="center", va="center", fontsize=FS_TINY) - - # Draw encoder - for x, y, w, h, label, _channels in encoder_layers: - draw_block(ax, x, y, w, h, label, ACCENT_LIGHT) - - # Draw arrows down (encoder) - for i in range(len(encoder_layers) - 1): - x1, y1 = encoder_layers[i][0], encoder_layers[i][1] - encoder_layers[i][3] / 2 - x2, y2 = ( - encoder_layers[i + 1][0], - encoder_layers[i + 1][1] + encoder_layers[i + 1][3] / 2, - ) - ax.annotate( - "", - xy=(x2, y2), - xytext=(x1, y1), - arrowprops={"arrowstyle": "->", "color": ACCENT, "lw": 2}, - ) - ax.text( - x1 - 1.7, - (y1 + y2) / 2, - "MaxPool\n2x2\n↓ zmniejsz", - fontsize=FS_TINY, - ha="center", - color=ACCENT, - fontweight="bold", - ) - - # Encoder to bottleneck - x1, y1 = encoder_layers[-1][0], encoder_layers[-1][1] - encoder_layers[-1][3] / 2 - draw_block( - ax, - bottleneck[0], - bottleneck[1], - bottleneck[2], - bottleneck[3], - bottleneck[4], - GRAY2, - ) - ax.annotate( - "", - xy=(bottleneck[0] - bottleneck[2] / 2, bottleneck[1] + bottleneck[3] / 2), - xytext=(x1, y1), - arrowprops={"arrowstyle": "->", "color": ACCENT, "lw": 2}, - ) - - # Bottleneck to decoder - ax.annotate( - "", - xy=( - decoder_layers[0][0] - decoder_layers[0][2] / 2, - decoder_layers[0][1] - decoder_layers[0][3] / 2, - ), - xytext=(bottleneck[0] + bottleneck[2] / 2, bottleneck[1] + bottleneck[3] / 2), - arrowprops={"arrowstyle": "->", "color": RED_ACCENT, "lw": 2}, - ) - - # Draw decoder - for x, y, w, h, label, channels in decoder_layers: - color = "#C8E6C9" if channels != "C" else "#A5D6A7" - draw_block(ax, x, y, w, h, label, color) - - # Draw arrows up (decoder) - for i in range(len(decoder_layers) - 1): - x1, y1 = decoder_layers[i][0], decoder_layers[i][1] + decoder_layers[i][3] / 2 - x2, y2 = ( - decoder_layers[i + 1][0], - decoder_layers[i + 1][1] - decoder_layers[i + 1][3] / 2, - ) - ax.annotate( - "", - xy=(x2, y2), - xytext=(x1, y1), - arrowprops={"arrowstyle": "->", "color": GREEN_ACCENT, "lw": 2}, - ) - ax.text( - x1 + 2, - (y1 + y2) / 2, - "UpConv\n2x2\n↑ zwiększ", - fontsize=FS_TINY, - ha="center", - color=GREEN_ACCENT, - fontweight="bold", - ) - - # Skip connections (horizontal arrows) - for i in range(len(encoder_layers)): - enc = encoder_layers[i] - dec = decoder_layers[len(decoder_layers) - 1 - i] - ax.annotate( - "", - xy=(dec[0] - dec[2] / 2, dec[1]), - xytext=(enc[0] + enc[2] / 2, enc[1]), - arrowprops={ - "arrowstyle": "->", - "color": GRAY5, - "lw": 1.5, - "linestyle": "dashed", - }, - ) - mid_x = (enc[0] + enc[2] / 2 + dec[0] - dec[2] / 2) / 2 - ax.text( - mid_x, - enc[1] + 0.6, - "skip\n(concat)", - fontsize=FS_TINY, - ha="center", - color=GRAY5, - fontweight="bold", - ) - - # Labels - ax.text( - 0, - 11.5, - "ENCODER\n(↓ zmniejsza)", - fontsize=FS, - fontweight="bold", - color=ACCENT, - ha="center", - ) - ax.text( - 17, - 11.5, - "DECODER\n(↑ zwiększa)", - fontsize=FS, - fontweight="bold", - color=GREEN_ACCENT, - ha="center", - ) - ax.text( - 8, - -0.8, - 'Kształt litery „U": encoder schodzi ↓ → bottleneck na dnie → decoder wraca ↑', - fontsize=FS_SMALL, - ha="center", - color=GRAY5, - fontweight="bold", - ) - - # Concatenation explanation - rect = FancyBboxPatch( - (17.5, 3), - 3, - 5, - boxstyle="round,pad=0.15", - facecolor=GRAY1, - edgecolor=GRAY5, - linewidth=1, - linestyle="--", - ) - ax.add_patch(rect) - ax.text( - 19, 7.5, "Concatenation:", fontsize=FS_SMALL, ha="center", fontweight="bold" - ) - ax.text( - 19, - 6.5, - "Encoder: 64 kanały\nDecoder: 64 kanały\n→ concat → 128 kanałów\n\n" - "Jak sklejenie\ndwóch stosów\nkart:", - fontsize=FS_TINY, - ha="center", - ) - ax.text( - 19, - 3.7, - "[enc₁|enc₂|...|dec₁|dec₂|...]", - fontsize=FS_TINY - 1, - ha="center", - fontweight="bold", - color=ACCENT, - ) - - _save_figure("q23_unet_arch.png") - - -# ============================================================ -# 9. RECEPTIVE FIELD — with dilation -# ============================================================ -def generate_receptive_field() -> None: - """Generate receptive field.""" - _fig, axes = plt.subplots(1, 3, figsize=(11, 4)) - - def draw_grid( - ax: Axes, - size: int, - highlight_cells: list[tuple[int, int]], - highlight_color: str, - title: str, - grid_offset: tuple[int, int] = (0, 0), - ) -> None: - """Draw grid.""" - ox, oy = grid_offset - for i in range(size): - for j in range(size): - color = WHITE - if (i, j) in highlight_cells: - color = highlight_color - rect = patches.Rectangle( - (ox + j, oy + size - 1 - i), - 1, - 1, - facecolor=color, - edgecolor=GRAY4, - linewidth=0.5, - ) - ax.add_patch(rect) - ax.set_title(title, fontsize=FS_TITLE, fontweight="bold") - - # --- Panel 1: Standard 3x3 conv receptive field --- - ax = axes[0] - ax.set_xlim(-0.5, 7.5) - ax.set_ylim(-1, 8) - ax.set_aspect("equal") - ax.axis("off") - - # 7x7 input grid - highlight_3x3 = [ - (2, 2), - (2, 3), - (2, 4), - (3, 2), - (3, 3), - (3, 4), - (4, 2), - (4, 3), - (4, 4), - ] - draw_grid(ax, 7, highlight_3x3, ACCENT_LIGHT, "Zwykła conv 3x3") - ax.text( - 3.5, - -0.5, - "RF = 3x3 pikseli", - fontsize=FS, - ha="center", - fontweight="bold", - color=ACCENT, - ) - - # --- Panel 2: Dilated conv (rate=2) --- - ax = axes[1] - ax.set_xlim(-0.5, 7.5) - ax.set_ylim(-1, 8) - ax.set_aspect("equal") - ax.axis("off") - - # 7x7 input grid with dilated highlights - highlight_dilated = [ - (1, 1), - (1, 3), - (1, 5), - (3, 1), - (3, 3), - (3, 5), - (5, 1), - (5, 3), - (5, 5), - ] - draw_grid(ax, 7, highlight_dilated, "#FFCDD2", "Dilated conv 3x3\n(rate=2)") - ax.text( - 3.5, - -0.5, - "RF = 5x5, ale 9 parametrów!", - fontsize=FS, - ha="center", - fontweight="bold", - color=RED_ACCENT, - ) - - # Connect dots to show pattern - dots_x = [1.5, 3.5, 5.5, 1.5, 3.5, 5.5, 1.5, 3.5, 5.5] - dots_y = [5.5, 5.5, 5.5, 3.5, 3.5, 3.5, 1.5, 1.5, 1.5] - ax.scatter(dots_x, dots_y, c=RED_ACCENT, s=30, zorder=5) - - # --- Panel 3: Comparison --- - ax = axes[2] - ax.set_xlim(0, 10) - ax.set_ylim(0, 10) - ax.axis("off") - ax.set_title( - "Receptive Field\n(pole widzenia neuronu)", fontsize=FS_TITLE, fontweight="bold" - ) - - y = 8.5 - lines = [ - ("RF = ile pikseli WEJŚCIOWYCH", FS, BLACK, "bold"), - ("wpływa na JEDEN piksel wyjścia", FS, BLACK, "bold"), - ("", 0, "", ""), - ("Rate (współczynnik dylatacji):", FS, BLACK, "bold"), - (' rate=1: filtr „dotyka" sąsiadów', FS_SMALL, BLACK, "normal"), - (" rate=2: co drugi piksel → RF = 5x5", FS_SMALL, BLACK, "normal"), - (" rate=3: co trzeci → RF = 7x7", FS_SMALL, BLACK, "normal"), - (" WIĘCEJ kontekstu, TE SAME wagi!", FS_SMALL, GREEN_ACCENT, "bold"), - ("", 0, "", ""), - ("Dlaczego ważne w segmentacji?", FS, BLACK, "bold"), - (" Piksel sam nie wie czym jest.", FS_SMALL, BLACK, "normal"), - (" Potrzebuje KONTEKSTU (otoczenia).", FS_SMALL, BLACK, "normal"), - (" Większe RF → widzi obok budynki", FS_SMALL, BLACK, "normal"), - (' → wie, że TEN piksel to „droga"', FS_SMALL, GREEN_ACCENT, "bold"), - ("", 0, "", ""), - ("Global Average Pooling:", FS, BLACK, "bold"), - (" Mapa HxWxC → 1x1xC", FS_SMALL, BLACK, "normal"), - (" Średnia z CAŁEGO feature map", FS_SMALL, BLACK, "normal"), - (" RF = nieskończone (cały obraz)", FS_SMALL, GREEN_ACCENT, "bold"), - ] - for txt, size, color, weight in lines: - if txt == "": - y -= 0.2 - continue - ax.text(0.5, y, txt, fontsize=size, color=color, fontweight=weight, va="top") - y -= 0.45 - - _save_figure("q23_receptive_field.png") - - -# ============================================================ -# 10. TRANSFORMER / Self-attention / SOTA -# ============================================================ -def generate_transformer() -> None: - """Generate transformer.""" - _fig, axes = plt.subplots(1, 3, figsize=(11, 4)) - - # --- Panel 1: CNN local vs Transformer global --- - ax = axes[0] - ax.set_xlim(-0.5, 8.5) - ax.set_ylim(-1.5, 8.5) - ax.set_aspect("equal") - ax.axis("off") - ax.set_title("CNN: widzi LOKALNIE", fontsize=FS_TITLE, fontweight="bold") - - # Draw 8x8 grid - for i in range(8): - for j in range(8): - color = WHITE - if (_HIGHLIGHT_START <= i <= _HIGHLIGHT_END - and _HIGHLIGHT_START <= j <= _HIGHLIGHT_END): - color = ACCENT_LIGHT - rect = patches.Rectangle( - (j, 7 - i), 1, 1, facecolor=color, edgecolor=GRAY3, linewidth=0.3 - ) - ax.add_patch(rect) - - # Highlight center - rect = patches.Rectangle( - (4, 4), 1, 1, facecolor=RED_ACCENT, edgecolor=BLACK, linewidth=1.5, alpha=0.7 - ) - ax.add_patch(rect) - ax.text( - 4.5, - 4.5, - "?", - ha="center", - va="center", - fontsize=FS, - fontweight="bold", - color=WHITE, - ) - ax.text( - 4.5, - -0.8, - "Filtr 3x3 widzi tylko\n9 sąsiednich pikseli", - fontsize=FS_SMALL, - ha="center", - color=ACCENT, - ) - - # --- Panel 2: Transformer global --- - ax = axes[1] - ax.set_xlim(-0.5, 8.5) - ax.set_ylim(-1.5, 8.5) - ax.set_aspect("equal") - ax.axis("off") - ax.set_title("Transformer: widzi GLOBALNIE", fontsize=FS_TITLE, fontweight="bold") - - # Draw 8x8 grid all highlighted - for i in range(8): - for j in range(8): - color = "#FFCDD2" - rect = patches.Rectangle( - (j, 7 - i), 1, 1, facecolor=color, edgecolor=GRAY3, linewidth=0.3 - ) - ax.add_patch(rect) - - rect = patches.Rectangle( - (4, 4), 1, 1, facecolor=RED_ACCENT, edgecolor=BLACK, linewidth=1.5, alpha=0.9 - ) - ax.add_patch(rect) - ax.text( - 4.5, - 4.5, - "?", - ha="center", - va="center", - fontsize=FS, - fontweight="bold", - color=WHITE, - ) - ax.text( - 4.5, - -0.8, - 'Self-attention „pyta"\nALL 64 piksele naraz', - fontsize=FS_SMALL, - ha="center", - color=RED_ACCENT, - ) - - # --- Panel 3: SOTA + Transformer explanation --- - ax = axes[2] - ax.set_xlim(0, 10) - ax.set_ylim(0, 10) - ax.axis("off") - ax.set_title("Transformer & SOTA", fontsize=FS_TITLE, fontweight="bold") - - y = 9.2 - lines = [ - ("Transformer:", FS, BLACK, "bold"), - (" Architektura z 2017 (Vaswani et al.)", FS_SMALL, BLACK, "normal"), - (" Oryginalnie do NLP (tłumaczenie)", FS_SMALL, BLACK, "normal"), - (" Kluczowy mechanizm: SELF-ATTENTION", FS_SMALL, ACCENT, "bold"), - ("", 0, "", ""), - ("Self-attention w skrócie:", FS, BLACK, "bold"), - (" Każdy piksel tworzy trzy wektory:", FS_SMALL, BLACK, "normal"), - (' Q (Query — „czego szukam?")', FS_SMALL, ACCENT, "normal"), - (' K (Key — „co oferuję innych")', FS_SMALL, RED_ACCENT, "normal"), - (' V (Value — „moja wartość")', FS_SMALL, GREEN_ACCENT, "normal"), - (" Attention = softmax(Q·Kᵀ/√d)·V", FS_SMALL, BLACK, "bold"), - (" Koszt: O(n²) — n=liczba pikseli", FS_SMALL, RED_ACCENT, "normal"), - ("", 0, "", ""), - ("SOTA = State Of The Art:", FS, BLACK, "bold"), - (" Najlepszy znany wynik na benchmarku", FS_SMALL, BLACK, "normal"), - (' Np. „mIoU 85.1% na ADE20K = SOTA"', FS_SMALL, BLACK, "normal"), - (" Ciągle się zmienia (nowy paper", FS_SMALL, GRAY5, "normal"), - (" → nowy SOTA)", FS_SMALL, GRAY5, "normal"), - ] - for txt, size, color, weight in lines: - if txt == "": - y -= 0.15 - continue - ax.text(0.3, y, txt, fontsize=size, color=color, fontweight=weight, va="top") - y -= 0.45 - - _save_figure("q23_transformer_attention.png") - - -def _draw_region_growing_grid(ax: Axes) -> None: - """Draw panel 2: region growing step-by-step grid.""" - ax.set_xlim(-0.5, 6.5) - ax.set_ylim(-1.5, 7.5) - ax.set_aspect("equal") - ax.axis("off") - ax.set_title( - "Region Growing: krok po kroku", - fontsize=FS_TITLE, fontweight="bold", - ) - - pixel_grid = np.array([ - [150, 153, 148, 200, 210, 205], - [147, 155, 152, 195, 208, 200], - [145, 148, 160, 190, 195, 210], - [200, 195, 190, 155, 148, 150], - [210, 205, 200, 150, 152, 145], - [215, 208, 195, 148, 147, 155], - ]) - region_mask = np.array([ - [1, 1, 1, 0, 0, 0], - [1, 1, 1, 0, 0, 0], - [1, 1, 1, 0, 0, 0], - [0, 0, 0, 1, 1, 1], - [0, 0, 0, 1, 1, 1], - [0, 0, 0, 1, 1, 1], - ]) - - for i in range(6): - for j in range(6): - v = pixel_grid[i, j] - if region_mask[i, j] == 1 and v < _BRIGHT_THRESHOLD: - cell_color = ACCENT_LIGHT - elif region_mask[i, j] == 1: - cell_color = GRAY2 - else: - cell_color = WHITE - if i == 1 and j == 1: - cell_color = "#FFD54F" - rect = patches.Rectangle( - (j, 5 - i), 1, 1, - facecolor=cell_color, edgecolor=GRAY4, - linewidth=0.5, - ) - ax.add_patch(rect) - ax.text( - j + 0.5, 5 - i + 0.5, str(v), - ha="center", va="center", - fontsize=FS_TINY, fontweight="bold", - ) - - ax.annotate( - "SEED\n(155)", xy=(1.5, 4.5), - fontsize=FS_SMALL, ha="center", - color=RED_ACCENT, fontweight="bold", - arrowprops={"arrowstyle": "->", "color": RED_ACCENT}, - xytext=(-0.5, 7), - ) - ax.text( - 3, -0.8, - "Próg = 20\nNiebieski = region (|val - seed| < 20)", - fontsize=FS_TINY, ha="center", color=ACCENT, - ) - - -def _draw_bfs_expansion(ax: Axes) -> None: - """Draw panel 3: BFS expansion visualization.""" - ax.set_xlim(-0.5, 6.5) - ax.set_ylim(-1.5, 7.5) - ax.set_aspect("equal") - ax.axis("off") - ax.set_title( - "Rosnący region (BFS)", - fontsize=FS_TITLE, fontweight="bold", - ) - - wave_colors = ["#FFD54F", "#FFF176", "#FFF9C4", ACCENT_LIGHT, "#B3D4FC"] - wave_labels = ["Seed", "Fala 1", "Fala 2", "Fala 3", "Fala 4"] - waves = [ - [(1, 1)], - [(0, 1), (1, 0), (1, 2), (2, 1)], - [(0, 0), (0, 2), (2, 0), (2, 2)], - ] - - for i in range(6): - for j in range(6): - cell_color = WHITE - for w_idx, wave in enumerate(waves): - if (i, j) in wave: - cell_color = wave_colors[w_idx] - rect = patches.Rectangle( - (j, 5 - i), 1, 1, - facecolor=cell_color, edgecolor=GRAY4, - linewidth=0.5, - ) - ax.add_patch(rect) - - seed_x, seed_y = 1.5, 4.5 - for dx, dy, _label in [ - (0, 1, ""), (0, -1, ""), (1, 0, ""), (-1, 0, ""), - ]: - ax.annotate( - "", - xy=(seed_x + dx * 0.7, seed_y + dy * 0.7), - xytext=(seed_x, seed_y), - arrowprops={ - "arrowstyle": "->", "color": RED_ACCENT, "lw": 1.2, - }, - ) - - ax.text( - 3, -0.5, - "BFS: sprawdzaj sąsiadów,\ndodawaj podobne do kolejki", - fontsize=FS_TINY, ha="center", color=GRAY5, - ) - - for w_idx, (wave_color, label) in enumerate( - zip(wave_colors[:3], wave_labels[:3], strict=False) - ): - rect = patches.Rectangle( - (4, 6.5 - w_idx * 0.7), 0.5, 0.5, - facecolor=wave_color, edgecolor=GRAY4, linewidth=0.5, - ) - ax.add_patch(rect) - ax.text( - 4.8, 6.75 - w_idx * 0.7, label, - fontsize=FS_TINY, va="center", - ) - - -# ============================================================ -# 11. REGION GROWING — seed selection + BFS -# ============================================================ -def generate_region_growing() -> None: - """Generate region growing.""" - _fig, axes = plt.subplots(1, 3, figsize=(11, 4.2)) - - # --- Panel 1: Manual vs automatic seed --- - ax = axes[0] - ax.set_xlim(0, 10) - ax.set_ylim(0, 10) - ax.axis("off") - ax.set_title("Seed: ręcznie vs automatycznie", fontsize=FS_TITLE, fontweight="bold") - - y = 9.2 - lines = [ - ("Ręczny seed:", FS, ACCENT, "bold"), - (" Użytkownik klika na obraz", FS_SMALL, BLACK, "normal"), - (' → „tu jest obiekt, od tego zacznij"', FS_SMALL, BLACK, "normal"), - (" Użycie: segmentacja interaktywna", FS_SMALL, GRAY5, "normal"), - (" (np. Photoshop — magic wand tool)", FS_SMALL, GRAY5, "normal"), - ("", 0, "", ""), - ("Automatyczny seed:", FS, RED_ACCENT, "bold"), - (" 1. Histogram → lokalne maxima", FS_SMALL, BLACK, "normal"), - (" (najczęstsza jasność → seed)", FS_SMALL, GRAY5, "normal"), - (" 2. Grid: siatka co N pikseli", FS_SMALL, BLACK, "normal"), - (" (np. seed co 50 px → 100 seedów)", FS_SMALL, GRAY5, "normal"), - (" 3. Losowe próbkowanie", FS_SMALL, BLACK, "normal"), - (" 4. Ekstrema lokalne gradientu", FS_SMALL, BLACK, "normal"), - ("", 0, "", ""), - ("Dlaczego OR?", FS, GREEN_ACCENT, "bold"), - (" Ręczny → precyzyjny, ale wolny", FS_SMALL, BLACK, "normal"), - (" Auto → szybki, ale over-segmentation", FS_SMALL, BLACK, "normal"), - ] - for txt, size, color, weight in lines: - if txt == "": - y -= 0.15 - continue - ax.text(0.3, y, txt, fontsize=size, color=color, fontweight=weight, va="top") - y -= 0.45 - - # --- Panel 2: Region growing step by step --- - _draw_region_growing_grid(axes[1]) - - # --- Panel 3: BFS expansion --- - _draw_bfs_expansion(axes[2]) - - _save_figure("q23_region_growing.png") - - -def _draw_otsu_variance_and_pseudocode( - ax_var: Axes, - ax_code: Axes, - img: np.ndarray, -) -> int: - """Draw panels 4 and 5: Otsu variance plot and pseudocode.""" - thresholds = range(10, 245) - variances = [] - for t in thresholds: - c0 = img[img <= t].ravel() - c1 = img[img > t].ravel() - if len(c0) == 0 or len(c1) == 0: - variances.append(np.nan) - continue - w0 = len(c0) / len(img.ravel()) - w1 = len(c1) / len(img.ravel()) - var = w0 * np.var(c0) + w1 * np.var(c1) - variances.append(var) - - ax_var.plot(list(thresholds), variances, color=ACCENT, linewidth=1.5) - best_t = list(thresholds)[np.nanargmin(variances)] - ax_var.axvline( - x=best_t, color=RED_ACCENT, - linewidth=1.5, linestyle="--", - label=f"Otsu T={best_t}", - ) - ax_var.scatter( - [best_t], [np.nanmin(variances)], - c=RED_ACCENT, s=60, zorder=5, - ) - ax_var.set_xlabel("Próg T", fontsize=FS_SMALL) - ax_var.set_ylabel("σ² wewnątrzklasowa", fontsize=FS_SMALL) - ax_var.set_title( - "Krok 4: Otsu szuka min σ²", - fontsize=FS, fontweight="bold", - ) - ax_var.legend(fontsize=FS_TINY) - - ax_code.set_xlim(0, 10) - ax_code.set_ylim(0, 10) - ax_code.axis("off") - ax_code.set_title("Pseudokod Otsu", fontsize=FS, fontweight="bold") - - code_lines = [ - "best_T = 0", "min_var = ∞", "", - "for T in 0..255:", - " c0 = piksele z jasność ≤ T", - " c1 = piksele z jasność > T", - " w0 = len(c0) / len(all)", - " w1 = len(c1) / len(all)", - " var = w0·var(c0) + w1·var(c1)", - " if var < min_var:", - " min_var = var", " best_T = T", "", - "return best_T # optymalny próg", - ] - for i, line in enumerate(code_lines): - txt_color = ( - ACCENT - if "best_T = T" in line or "return" in line - else BLACK - ) - ax_code.text( - 0.5, 9.5 - i * 0.65, line, - fontsize=FS_TINY, fontfamily="monospace", - color=txt_color, - fontweight="bold" if txt_color == ACCENT else "normal", - ) - return int(best_t) - - -# ============================================================ -# 12. DIY THRESHOLDING — Step-by-step example -# ============================================================ -def generate_diy_thresholding() -> None: - """Generate diy thresholding.""" - _fig, axes = plt.subplots(2, 3, figsize=(11, 7)) - - # Create a simple synthetic image: dark circle on bright background - size = 64 - img = np.ones((size, size)) * 200 # bright background - yy, xx = np.mgrid[:size, :size] - mask = ((xx - 32) ** 2 + (yy - 32) ** 2) < 15**2 - img[mask] = 60 # dark circle - # Add some noise - img += rng.normal(0, 10, img.shape) - img = np.clip(img, 0, 255) - - # --- Panel 1: Original image --- - ax = axes[0, 0] - ax.imshow(img, cmap="gray", vmin=0, vmax=255) - ax.set_title("Krok 1: obraz wejściowy", fontsize=FS, fontweight="bold") - ax.axis("off") - ax.text(32, -3, "64x64 pikseli, szare", fontsize=FS_TINY, ha="center") - - # --- Panel 2: Histogram --- - ax = axes[0, 1] - counts, _bins, _ = ax.hist( - img.ravel(), bins=50, color=GRAY3, edgecolor=GRAY5, linewidth=0.5 - ) - ax.axvline( - x=128, color=RED_ACCENT, linewidth=2, linestyle="--", label="T=128 (Otsu)" - ) - ax.set_xlabel("Jasność", fontsize=FS_SMALL) - ax.set_ylabel("Piksele", fontsize=FS_SMALL) - ax.set_title("Krok 2: histogram\n(bimodalny!)", fontsize=FS, fontweight="bold") - ax.legend(fontsize=FS_TINY) - ax.annotate( - "Garb 1\n(obiekt)", - xy=(60, max(counts) * 0.5), - fontsize=FS_TINY, - ha="center", - color=ACCENT, - fontweight="bold", - ) - ax.annotate( - "Garb 2\n(tło)", - xy=(200, max(counts) * 0.5), - fontsize=FS_TINY, - ha="center", - color=RED_ACCENT, - fontweight="bold", - ) - - # --- Panel 3: Thresholding result --- - ax = axes[0, 2] - binary = (img > _OTSU_THRESHOLD).astype(float) - ax.imshow(binary, cmap="gray", vmin=0, vmax=1) - ax.set_title("Krok 3: progowanie T=128", fontsize=FS, fontweight="bold") - ax.axis("off") - ax.text(32, -3, "Biały = tło, Czarny = obiekt", fontsize=FS_TINY, ha="center") - - # --- Panels 4+5: Otsu variance plot + pseudocode --- - best_t = _draw_otsu_variance_and_pseudocode( - axes[1, 0], axes[1, 1], img, - ) - - # --- Panel 6: Final result with Otsu --- - ax = axes[1, 2] - binary_otsu = (img > best_t).astype(float) - ax.imshow(binary_otsu, cmap="gray", vmin=0, vmax=1) - ax.set_title(f"Krok 5: wynik Otsu (T={best_t})", fontsize=FS, fontweight="bold") - ax.axis("off") - ax.text( - 32, - -3, - "Automatyczny próg!", - fontsize=FS_TINY, - ha="center", - color=GREEN_ACCENT, - fontweight="bold", - ) - - _save_figure("q23_diy_thresholding.png") - - -def _draw_unet_layer_stack( - ax: Axes, - layer_sizes: list[tuple[int, int]], - *, - face_color: str, - edge_color: str, - arrow_color: str, - arrow_label: str, - add_skip: bool = False, -) -> None: - """Draw encoder or decoder layer stack for DIY U-Net.""" - ax.set_xlim(0, 10) - ax.set_ylim(0, 10) - ax.axis("off") - - y_pos = 8.5 - for i, (s, c) in enumerate(layer_sizes): - w = s / 64 * 4 - h = 0.8 - rect = FancyBboxPatch( - (5 - w / 2, y_pos), w, h, - boxstyle="round,pad=0.05", - facecolor=face_color, edgecolor=edge_color, - linewidth=1, - ) - ax.add_patch(rect) - label = f"{s}x{s}x{c}" - if add_skip and i < len(layer_sizes) - 1: - label += " + skip!" - ax.text( - 5, y_pos + h / 2, label, - ha="center", va="center", - fontsize=FS_SMALL, fontweight="bold", - ) - if i < len(layer_sizes) - 1: - ax.annotate( - "", xy=(5, y_pos - 0.3), xytext=(5, y_pos), - arrowprops={ - "arrowstyle": "->", "color": arrow_color, - "lw": 1.5, - }, - ) - ax.text( - 7, y_pos - 0.15, arrow_label, - fontsize=FS_TINY, color=arrow_color, - ) - y_pos -= 2.2 - - -def _draw_unet_pseudocode(ax: Axes) -> None: - """Draw panel 6: U-Net pseudocode.""" - ax.set_xlim(0, 10) - ax.set_ylim(0, 10) - ax.axis("off") - ax.set_title("Pseudokod U-Net", fontsize=FS, fontweight="bold") - - code_lines = [ - "# ENCODER", - "e1 = conv_block(input, 64) # 64x64", - "e2 = conv_block(pool(e1), 128) # 32x32", - "e3 = conv_block(pool(e2), 256) # 16x16", - "", - "# BOTTLENECK", - "b = conv_block(pool(e3), 512) # 8x8", - "", - "# DECODER + SKIP", - "d3 = conv_block(concat(", - " upconv(b), e3), 256) # 16x16", - "d2 = conv_block(concat(", - " upconv(d3), e2), 128) # 32x32", - "d1 = conv_block(concat(", - " upconv(d2), e1), 64) # 64x64", - "", - "output = conv_1x1(d1, n_classes)", - ] - for i, line in enumerate(code_lines): - txt_color = ( - ACCENT - if "concat" in line - else (GREEN_ACCENT if "output" in line else BLACK) - ) - ax.text( - 0.3, 9.5 - i * 0.55, line, - fontsize=FS_TINY, fontfamily="monospace", - color=txt_color, - ) - - -# ============================================================ -# 13. DIY U-NET — Simplified step-by-step -# ============================================================ -def generate_diy_unet() -> None: - """Generate diy unet.""" - fig, axes = plt.subplots(2, 3, figsize=(11, 7)) - - size = 64 - - # Create synthetic image with two regions - img = np.ones((size, size, 3), dtype=np.uint8) * 200 # bright bg - # Dark region (object 1) - yy, xx = np.mgrid[:size, :size] - mask1 = ((xx - 20) ** 2 + (yy - 30) ** 2) < 12**2 - img[mask1] = [60, 60, 60] - # Medium region (object 2) - mask2 = ((xx - 45) ** 2 + (yy - 25) ** 2) < 8**2 - img[mask2] = [120, 120, 120] - - gt = np.zeros((size, size), dtype=np.uint8) - gt[mask1] = 1 # class 1 - gt[mask2] = 2 # class 2 - - # --- Panel 1: Input image --- - ax = axes[0, 0] - ax.imshow(img) - ax.set_title("Krok 1: obraz RGB\n64x64x3", fontsize=FS, fontweight="bold") - ax.axis("off") - - # --- Panel 2: Encoder shrinks --- - ax = axes[0, 1] - ax.set_title("Krok 2: Encoder ZMNIEJSZA", fontsize=FS, fontweight="bold") - _draw_unet_layer_stack( - ax, [(64, 3), (32, 64), (16, 128), (8, 256)], - face_color=ACCENT_LIGHT, edge_color=ACCENT, - arrow_color=ACCENT, arrow_label="Conv+Pool", - ) - ax.text( - 5, 0.3, - "Wyciąga cechy:\nkrawędzie → tekstury → obiekty", - ha="center", fontsize=FS_TINY, color=GRAY5, - ) - - # --- Panel 3: Bottleneck --- - ax = axes[0, 2] - # Show feature maps at bottleneck (abstract) - ax.set_xlim(0, 10) - ax.set_ylim(0, 10) - ax.axis("off") - ax.set_title( - "Krok 3: Bottleneck\n(najbardziej abstrakcyjne cechy)", - fontsize=FS, - fontweight="bold", - ) - - # Show small abstract feature maps - for k in range(4): - small = rng.random((4, 4)) - ax_inset = fig.add_axes( - [0.68 + (k % 2) * 0.08, 0.72 - (k // 2) * 0.1, 0.06, 0.06] - ) - ax_inset.imshow(small, cmap="gray") - ax_inset.axis("off") - - ax.text( - 5, - 5, - '8x8x256\n\nMałe mapy, ale DUŻO kanałów\nKażdy kanał = jedna „cecha"\n' - '(np. kanał 42 = „wykrył koło"\n kanał 78 = „wykrył krawędź")\n\n' - "Wie CO jest na obrazie\nale nie wie GDZIE dokładnie", - ha="center", - va="center", - fontsize=FS_SMALL, - bbox={"boxstyle": "round", "facecolor": GRAY1, "edgecolor": GRAY3}, - ) - - # --- Panel 4: Decoder enlarges --- - ax = axes[1, 0] - ax.set_title( - "Krok 4: Decoder ZWIĘKSZA\n(+ skip connections!)", - fontsize=FS, - fontweight="bold", - ) - _draw_unet_layer_stack( - ax, [(8, 256), (16, 128), (32, 64), (64, 3)], - face_color="#C8E6C9", edge_color=GREEN_ACCENT, - arrow_color=GREEN_ACCENT, arrow_label="UpConv+Concat", - add_skip=True, - ) - ax.text( - 5, 0.3, - "Odtwarza rozdzielczość:\nskip → przywraca krawędzie", - ha="center", fontsize=FS_TINY, color=GRAY5, - ) - - # --- Panel 5: Output segmentation map --- - ax = axes[1, 1] - cmap = plt.cm.colors.ListedColormap([WHITE, ACCENT_LIGHT, "#FFCDD2"]) - ax.imshow(gt, cmap=cmap, interpolation="nearest") - ax.set_title( - "Krok 5: mapa segmentacji\n64x64 (3 klasy)", fontsize=FS, fontweight="bold" - ) - ax.axis("off") - ax.text(20, -3, "Tło=0, obiekt A=1, obiekt B=2", fontsize=FS_TINY, ha="center") - - # --- Panel 6: Summary pseudocode --- - _draw_unet_pseudocode(axes[1, 2]) - - _save_figure("q23_diy_unet.png") - - -# ============================================================ -# 14. MNEMONICS — Visual mnemonic summary -# ============================================================ -def generate_mnemonics() -> None: - """Generate mnemonics.""" - _fig, ax = plt.subplots(1, 1, figsize=(10, 8)) - ax.set_xlim(0, 20) - ax.set_ylim(0, 16) - ax.axis("off") - ax.set_title( - "Mnemoniki — segmentacja obrazu", fontsize=FS_TITLE + 2, fontweight="bold" - ) - - def draw_card( - ax: Axes, - x: float, - y: float, - w: float, - h: float, - title: str, - mnemonic: str, - color: str, - detail: str = "", - ) -> None: - """Draw card.""" - rect = FancyBboxPatch( - (x, y), - w, - h, - boxstyle="round,pad=0.15", - facecolor=color, - edgecolor=BLACK, - linewidth=1, - ) - ax.add_patch(rect) - ax.text( - x + w / 2, - y + h - 0.3, - title, - ha="center", - va="top", - fontsize=FS, - fontweight="bold", - ) - ax.text( - x + w / 2, - y + h / 2 - 0.1, - mnemonic, - ha="center", - va="center", - fontsize=FS_SMALL, - fontstyle="italic", - color=GRAY6, - ) - if detail: - ax.text( - x + w / 2, - y + 0.4, - detail, - ha="center", - va="bottom", - fontsize=FS_TINY, - color=GRAY5, - ) - - # Title: STRATEGIE KLASYCZNE - ax.text( - 5, - 15.5, - "STRATEGIE KLASYCZNE", - fontsize=FS_TITLE, - fontweight="bold", - color=ACCENT, - ha="center", - ) - - cards_classic = [ - ( - 0.2, - 12.5, - 4.5, - 2.5, - "Thresholding", - '„PRÓG na bramce"\nPrzepuszcza > T,\nblokuje ≤ T', - ACCENT_LIGHT, - "jasne=1, ciemne=0", - ), - ( - 5, - 12.5, - 4.5, - 2.5, - "Otsu", - '„AUTO-bramkarz"\nSam dobiera próg\nmin σ² wewnątrz', - ACCENT_LIGHT, - "histogram bimodalny", - ), - ( - 0.2, - 9.5, - 4.5, - 2.5, - "Region Growing", - '„PLAMA rozlana"\nSeed → BFS po\npodobnych sąsiadach', - ACCENT_LIGHT, - "jak atrament na papierze", - ), - ( - 5, - 9.5, - 4.5, - 2.5, - "Watershed", - '„ZALEWANIE terenu"\nDoliny=obiekty\nGranie=granice', - ACCENT_LIGHT, - "woda + geography", - ), - ( - 0.2, - 6.5, - 4.5, - 2.5, - "Mean Shift", - '„KULKI toczą się"\nKażda → max gęstości\nBez K!', - ACCENT_LIGHT, - "bandwidth = okno", - ), - ( - 5, - 6.5, - 4.5, - 2.5, - "Normalized Cuts", - '„CIĘCIE sznurków"\nGraf: tnij słabe\nkrawędzie (O(n³)!)', - ACCENT_LIGHT, - "eigenvector problem", - ), - ] - - for args in cards_classic: - draw_card(ax, *args) - - # Title: SIECI NEURONOWE - ax.text( - 15, - 15.5, - "SIECI NEURONOWE", - fontsize=FS_TITLE, - fontweight="bold", - color=GREEN_ACCENT, - ha="center", - ) - - cards_nn = [ - ( - 10.5, - 12.5, - 4.5, - 2.5, - "FCN (2015)", - '„FC → Conv 1x1"\nPierwsza end-to-end\nDowolny rozmiar', - "#C8E6C9", - "skip connections", - ), - ( - 15.3, - 12.5, - 4.5, - 2.5, - "U-Net (2015)", - '„Litera U"\nEncoder↓ Decoder↑\nSkip = concat', - "#C8E6C9", - "medycyna, małe dane", - ), - ( - 10.5, - 9.5, - 4.5, - 2.5, - "DeepLab v3+", - '„DZIURY w filtrze"\nAtrous conv (rate)\nASPP multi-scale', - "#C8E6C9", - "à trous = z dziurami", - ), - ( - 15.3, - 9.5, - 4.5, - 2.5, - "Transformer", - '„WSZYSCY ze\nWSZYSTKIMI"\nSelf-attention O(n²)', - "#C8E6C9", - "SegFormer, Mask2Former", - ), - ] - - for args in cards_nn: - draw_card(ax, *args) - - # Metryki - ax.text( - 10, - 8.3, - "METRYKI I LOSS", - fontsize=FS_TITLE, - fontweight="bold", - color=RED_ACCENT, - ha="center", - ) - - cards_metrics = [ - ( - 10.5, - 6.5, - 4.5, - 1.6, - "mIoU", - '„Nakładka / Suma"\nIoU = A∩B / A\u222aB', - "#FFCDD2", - "", - ), - ( - 15.3, - 6.5, - 4.5, - 1.6, - "Dice / Focal", - '„Dice=2·nakładka"\nFocal=trudne px', - "#FFCDD2", - "", - ), - ] - - for args in cards_metrics: - draw_card(ax, *args) - - # Master mnemonic at bottom - rect = FancyBboxPatch( - (1, 0.3), - 18, - 5.5, - boxstyle="round,pad=0.2", - facecolor=GRAY1, - edgecolor=BLACK, - linewidth=1.5, - ) - ax.add_patch(rect) - ax.text( - 10, - 5.3, - "SUPER-MNEMONIK: kolejność algorytmów segmentacji", - ha="center", - fontsize=FS, - fontweight="bold", - ) - ax.text( - 10, - 4.5, - '„TORW-MN FUD-T"', - ha="center", - fontsize=FS_TITLE + 2, - fontweight="bold", - color=RED_ACCENT, - ) - ax.text( - 10, - 3.5, - "Klasyczne: Thresholding → Otsu → Region" - " growing → Watershed → Mean shift → Norm. cuts", - ha="center", - fontsize=FS_SMALL, - ) - ax.text( - 10, - 2.8, - "Neuronowe: FCN → U-Net → DeepLab → Transformer", - ha="center", - fontsize=FS_SMALL, - ) - ax.text( - 10, - 1.8, - '„Turyści Oglądają Rzekę, Wodospad,' - ' Morze, Nurt — Fotografują Uroczy' - ' Dwór Tajemnic"', - ha="center", - fontsize=FS_SMALL, - fontstyle="italic", - color=ACCENT, - ) - ax.text( - 10, - 1.0, - "Klasyczne: proste→auto→BFS→flood→" - "gęstość→graf | Neuronowe:" - " FC→U-skip→dilated→attention", - ha="center", - fontsize=FS_TINY, - color=GRAY5, - ) - - _save_figure("q23_mnemonics.png") - - # ============================================================ # MAIN # ============================================================ diff --git a/python_pkg/praca_magisterska_video/generate_images/generate_q24_diagrams.py b/python_pkg/praca_magisterska_video/generate_images/generate_q24_diagrams.py old mode 100755 new mode 100644 index 78dded6..f57b4aa --- a/python_pkg/praca_magisterska_video/generate_images/generate_q24_diagrams.py +++ b/python_pkg/praca_magisterska_video/generate_images/generate_q24_diagrams.py @@ -2,2274 +2,72 @@ """Generate ALL diagrams for PYTANIE 24: Detekcja obiektów. Monochrome, A4-printable PNGs (300 DPI). +Re-exports all diagram generators from submodules. """ from __future__ import annotations import logging from pathlib import Path -from typing import TYPE_CHECKING +import sys -import matplotlib as mpl +# Ensure sibling modules are importable when run as a script. +sys.path.insert(0, str(Path(__file__).resolve().parent)) -mpl.use("Agg") +from _q24_common import OUTPUT_DIR +from _q24_fpn_tasks_cnn import ( + draw_anchor_boxes, + draw_cnn_architecture, + draw_detection_tasks, + draw_fpn, +) +from _q24_haar_integral_svm import ( + draw_haar_features, + draw_integral_image, + draw_svm_hyperplane, +) +from _q24_hog_classical import ( + draw_hog_gradient_steps, + draw_hog_svm_pipeline, + draw_viola_jones_cascade, +) +from _q24_iou_nms_detector import ( + draw_detector_from_classifier, + draw_iou_diagram, + draw_nms_steps, +) +from _q24_modern_pipelines import ( + draw_detr_pipeline, + draw_roi_pooling, + draw_sliding_window, + draw_two_vs_one_stage, +) +from _q24_rcnn_yolo import draw_rcnn_evolution, draw_yolo_grid -import matplotlib.patches as mpatches -from matplotlib.patches import FancyBboxPatch -import matplotlib.pyplot as plt -import numpy as np - -if TYPE_CHECKING: - from matplotlib.axes import Axes - from matplotlib.figure import Figure +__all__ = [ + "draw_anchor_boxes", + "draw_cnn_architecture", + "draw_detection_tasks", + "draw_detector_from_classifier", + "draw_detr_pipeline", + "draw_fpn", + "draw_haar_features", + "draw_hog_gradient_steps", + "draw_hog_svm_pipeline", + "draw_integral_image", + "draw_iou_diagram", + "draw_nms_steps", + "draw_rcnn_evolution", + "draw_roi_pooling", + "draw_sliding_window", + "draw_svm_hyperplane", + "draw_two_vs_one_stage", + "draw_viola_jones_cascade", + "draw_yolo_grid", +] _logger = logging.getLogger(__name__) -rng = np.random.default_rng(42) - -DPI = 300 -BG = "white" -LN = "black" -FS = 8 -FS_TITLE = 11 -FS_SMALL = 6.5 -FS_LABEL = 9 -OUTPUT_DIR = str(Path(__file__).resolve().parent / "img") -Path(OUTPUT_DIR).mkdir(parents=True, exist_ok=True) - -GRAY1 = "#E8E8E8" -GRAY2 = "#D0D0D0" -GRAY3 = "#B8B8B8" -GRAY4 = "#F5F5F5" -GRAY5 = "#C0C0C0" - -_PIXEL_BRIGHT_THRESH = 127 -_GRADIENT_BRIGHT_THRESH = 100 -_DATA_BRIGHT_THRESH = 5 -_II_BRIGHT_THRESH = 25 -_DOTS_STAGE_IDX = 2 - - -def draw_box( - ax: Axes, - x: float, - y: float, - w: float, - h: float, - text: str, - *, - fill: str = "white", - lw: float = 1.2, - fontsize: float = FS, - fontweight: str = "normal", - ha: str = "center", - va: str = "center", - rounded: bool = True, - edgecolor: str = LN, - linestyle: str = "-", -) -> None: - """Draw box.""" - if rounded: - rect = FancyBboxPatch( - (x, y), - w, - h, - boxstyle="round,pad=0.05", - lw=lw, - edgecolor=edgecolor, - facecolor=fill, - linestyle=linestyle, - ) - else: - rect = mpatches.Rectangle( - (x, y), - w, - h, - lw=lw, - edgecolor=edgecolor, - facecolor=fill, - linestyle=linestyle, - ) - ax.add_patch(rect) - ax.text( - x + w / 2, - y + h / 2, - text, - ha=ha, - va=va, - fontsize=fontsize, - fontweight=fontweight, - wrap=True, - ) - - -def draw_arrow( - ax: Axes, - x1: float, - y1: float, - x2: float, - y2: float, - *, - lw: float = 1.2, - style: str = "->", - color: str = LN, -) -> None: - """Draw arrow.""" - ax.annotate( - "", - xy=(x2, y2), - xytext=(x1, y1), - arrowprops={"arrowstyle": style, "color": color, "lw": lw}, - ) - - -def save_fig(fig: Figure, name: str) -> None: - """Save fig.""" - path = str(Path(OUTPUT_DIR) / name) - fig.savefig(path, dpi=DPI, bbox_inches="tight", facecolor=BG, pad_inches=0.15) - plt.close(fig) - _logger.info(" Saved: %s", path) - - -def draw_table( - ax: Axes, - headers: list[str], - rows: list[list[str]], - x0: float, - y0: float, - col_widths: list[float], - *, - row_h: float = 0.4, - header_fill: str = GRAY2, - row_fills: list[str] | None = None, - fontsize: float = FS, - header_fontsize: float | None = None, -) -> None: - """Draw table.""" - if header_fontsize is None: - header_fontsize = fontsize - len(headers) - cx = x0 - for j, hdr in enumerate(headers): - draw_box( - ax, - cx, - y0, - col_widths[j], - row_h, - hdr, - fill=header_fill, - fontsize=header_fontsize, - fontweight="bold", - rounded=False, - ) - cx += col_widths[j] - for i, row in enumerate(rows): - cy = y0 - (i + 1) * row_h - cx = x0 - fill = GRAY4 if (i % 2 == 0) else "white" - if row_fills and i < len(row_fills): - fill = row_fills[i] - for j, cell in enumerate(row): - fw = "bold" if j == 0 else "normal" - draw_box( - ax, - cx, - cy, - col_widths[j], - row_h, - cell, - fill=fill, - fontsize=fontsize, - fontweight=fw, - rounded=False, - ) - cx += col_widths[j] - - -# ============================================================ -# 1. HOG + SVM Pipeline -# ============================================================ -def draw_hog_svm_pipeline() -> None: - """Draw hog svm pipeline.""" - fig, ax = plt.subplots(figsize=(10, 4.5)) - ax.set_xlim(-0.5, 10.5) - ax.set_ylim(-1, 4.5) - ax.set_aspect("equal") - ax.axis("off") - ax.set_title( - "HOG + SVM — pipeline detekcji pieszych", - fontsize=FS_TITLE, - fontweight="bold", - pad=12, - ) - - # Step 1: Image with sliding window - ax.add_patch( - mpatches.Rectangle((0, 1.5), 2, 2, lw=1.5, edgecolor=LN, facecolor=GRAY1) - ) - ax.text(1, 2.5, "Obraz\nwejściowy", ha="center", va="center", fontsize=FS) - # sliding window overlay - ax.add_patch( - mpatches.Rectangle( - (0.3, 1.8), - 0.8, - 1.2, - lw=1.5, - edgecolor="black", - facecolor="none", - linestyle="--", - ) - ) - ax.text( - 0.7, - 1.35, - "okno 64x128", - ha="center", - va="center", - fontsize=FS_SMALL, - style="italic", - ) - - draw_arrow(ax, 2.1, 2.5, 2.8, 2.5, lw=1.5) - ax.text(2.45, 2.75, "①", ha="center", fontsize=FS_LABEL, fontweight="bold") - - # Step 2: Gradient computation - draw_box( - ax, 2.9, 1.8, 1.6, 1.4, "Oblicz\ngradienty\nGx, Gy", fill=GRAY4, fontsize=FS - ) - ax.text( - 3.7, 1.55, "kierunek + siła", ha="center", fontsize=FS_SMALL, style="italic" - ) - - draw_arrow(ax, 4.6, 2.5, 5.2, 2.5, lw=1.5) - ax.text(4.9, 2.75, "②", ha="center", fontsize=FS_LABEL, fontweight="bold") - - # Step 3: HOG histogram - draw_box( - ax, - 5.3, - 1.8, - 1.6, - 1.4, - "Histogramy\nkierunkowe\n9 binów/cel", - fill=GRAY4, - fontsize=FS, - ) - ax.text(6.1, 1.55, "komórki 8x8 px", ha="center", fontsize=FS_SMALL, style="italic") - - draw_arrow(ax, 7.0, 2.5, 7.6, 2.5, lw=1.5) - ax.text(7.3, 2.75, "③", ha="center", fontsize=FS_LABEL, fontweight="bold") - - # Step 4: SVM - draw_box( - ax, - 7.7, - 1.8, - 1.4, - 1.4, - "SVM\nklasyfikator\npieszy/tło", - fill=GRAY3, - fontsize=FS, - fontweight="bold", - ) - - draw_arrow(ax, 9.2, 2.5, 9.7, 2.5, lw=1.5) - ax.text(9.45, 2.75, "④", ha="center", fontsize=FS_LABEL, fontweight="bold") - - # Step 5: NMS + output - draw_box(ax, 9.3, 2.0, 1.0, 1.0, "NMS\n→ wynik", fill=GRAY1, fontsize=FS) - - # Bottom: HOG feature vector illustration - ax.text( - 5.0, - 0.7, - "Wektor HOG: 3780 cech = 105 bloków x 4 komórki x 9 binów", - ha="center", - fontsize=FS, - style="italic", - bbox={"boxstyle": "round,pad=0.3", "facecolor": GRAY4, "edgecolor": GRAY3}, - ) - - # Show small histogram bars - bar_x = 3.2 - bar_y = 0.0 - angles = [0, 20, 40, 60, 80, 100, 120, 140, 160] - values = [0.3, 0.1, 0.5, 0.8, 0.2, 0.6, 0.15, 0.4, 0.25] - for i, (_a, v) in enumerate(zip(angles, values, strict=False)): - ax.add_patch( - mpatches.Rectangle( - (bar_x + i * 0.18, bar_y), - 0.15, - v * 0.6, - facecolor=GRAY3, - edgecolor=LN, - lw=0.5, - ) - ) - ax.text(bar_x + 0.8, -0.2, "9 binów (0°-160°)", ha="center", fontsize=FS_SMALL) - - save_fig(fig, "q24_hog_svm_pipeline.png") - - -# ============================================================ -# 2. HOG Gradient Step-by-Step -# ============================================================ -def draw_hog_gradient_steps() -> None: - """Draw hog gradient steps.""" - fig, axes = plt.subplots(1, 4, figsize=(12, 3.5)) - fig.suptitle( - "HOG — kroki obliczania cech", fontsize=FS_TITLE, fontweight="bold", y=1.02 - ) - - # Step 1: Original patch - ax = axes[0] - patch = np.array([[50, 50, 200], [50, 50, 200], [50, 50, 200]]) - ax.imshow(patch, cmap="gray", vmin=0, vmax=255) - for i in range(3): - for j in range(3): - ax.text( - j, - i, - str(patch[i, j]), - ha="center", - va="center", - fontsize=FS_LABEL, - fontweight="bold", - color="white" if patch[i, j] > _PIXEL_BRIGHT_THRESH else "black", - ) - ax.set_title("① Fragment obrazu\n(jasność pikseli)", fontsize=FS, fontweight="bold") - ax.set_xticks([]) - ax.set_yticks([]) - - # Step 2: Gradient magnitude - ax = axes[1] - gx = np.array([[0, 150, 0], [0, 150, 0], [0, 150, 0]]) - ax.imshow(gx, cmap="gray", vmin=0, vmax=255) - for i in range(3): - for j in range(3): - ax.text( - j, - i, - str(gx[i, j]), - ha="center", - va="center", - fontsize=FS_LABEL, - fontweight="bold", - color="white" if gx[i, j] > _GRADIENT_BRIGHT_THRESH else "black", - ) - ax.set_title("② Gradient Gx\n(krawędź pionowa!)", fontsize=FS, fontweight="bold") - ax.set_xticks([]) - ax.set_yticks([]) - - # Step 3: Cell histogram - ax = axes[2] - angles = ["0°", "20°", "40°", "60°", "80°", "100°", "120°", "140°", "160°"] - values = [150, 0, 0, 0, 0, 0, 0, 0, 0] - bars = ax.bar(range(9), values, color=GRAY3, edgecolor=LN, linewidth=0.5) - bars[0].set_facecolor(GRAY5) - ax.set_xticks(range(9)) - ax.set_xticklabels(angles, fontsize=5, rotation=45) - ax.set_title( - "③ Histogram komórki\n(bin 0° = krawędź pionowa)", - fontsize=FS, - fontweight="bold", - ) - ax.set_ylabel("siła", fontsize=FS_SMALL) - - # Step 4: Block normalization - ax = axes[3] - # 2x2 block of cells - for i in range(2): - for j in range(2): - rect = mpatches.Rectangle( - (j * 1.2, (1 - i) * 1.2), - 1.0, - 1.0, - lw=1.2, - edgecolor=LN, - facecolor=GRAY4, - ) - ax.add_patch(rect) - ax.text( - j * 1.2 + 0.5, - (1 - i) * 1.2 + 0.5, - f"hist\n{i * 2 + j + 1}", - ha="center", - va="center", - fontsize=FS_SMALL, - ) - ax.add_patch( - mpatches.Rectangle( - (-0.1, -0.1), 2.6, 2.6, lw=2, edgecolor=LN, facecolor="none", linestyle="--" - ) - ) - ax.text( - 1.2, - -0.4, - "blok 2x2 → L2-norm", - ha="center", - fontsize=FS_SMALL, - fontweight="bold", - ) - ax.set_xlim(-0.3, 2.8) - ax.set_ylim(-0.7, 2.8) - ax.set_aspect("equal") - ax.axis("off") - ax.set_title( - "④ Normalizacja bloków\n(odporność na oświetlenie)", - fontsize=FS, - fontweight="bold", - ) - - fig.tight_layout() - save_fig(fig, "q24_hog_gradient_steps.png") - - -# ============================================================ -# 3. Viola-Jones Cascade -# ============================================================ -def draw_viola_jones_cascade() -> None: - """Draw viola jones cascade.""" - fig, ax = plt.subplots(figsize=(10, 5)) - ax.set_xlim(-0.5, 10.5) - ax.set_ylim(-1.5, 5) - ax.set_aspect("equal") - ax.axis("off") - ax.set_title( - "Viola-Jones — kaskada klasyfikatorów (SITO)", - fontsize=FS_TITLE, - fontweight="bold", - pad=12, - ) - - # Input - draw_box( - ax, - -0.3, - 2.5, - 1.5, - 1.2, - "500 000\nokien", - fill=GRAY1, - fontsize=FS, - fontweight="bold", - ) - - stages = [ - ("Etap 1\n2 cechy", "50%\nodrzucone", "250 000", GRAY4), - ("Etap 2\n10 cech", "80%\nodrzucone", "50 000", GRAY4), - ("Etap 3\n25 cech", "90%\nodrzucone", "5 000", GRAY4), - ("Etap 25\n200 cech", "99%\nodrzucone", "50", GRAY3), - ] - - x_pos = 1.6 - for i, (label, reject, remain, col) in enumerate(stages): - # Stage box - draw_box( - ax, x_pos, 2.5, 1.6, 1.2, label, fill=col, fontsize=FS, fontweight="bold" - ) - - # Arrow from previous - draw_arrow(ax, x_pos - 0.3, 3.1, x_pos - 0.05, 3.1, lw=1.5) - - # Reject arrow down - draw_arrow(ax, x_pos + 0.8, 2.45, x_pos + 0.8, 1.6, lw=1.2) - ax.text( - x_pos + 0.8, - 1.3, - reject, - ha="center", - fontsize=FS_SMALL, - color="black", - style="italic", - ) - ax.text( - x_pos + 0.8, - 0.8, - "✗ NIE-TWARZ", - ha="center", - fontsize=FS_SMALL, - fontweight="bold", - ) - - # Remaining count above - if i < len(stages) - 1: - ax.text( - x_pos + 2.0, - 3.9, - f"→ {remain}", - ha="center", - fontsize=FS_SMALL, - style="italic", - ) - - # Dots between stage 3 and stage 25 - if i == _DOTS_STAGE_IDX: - ax.text( - x_pos + 2.0, 3.1, "· · ·", ha="center", fontsize=12, fontweight="bold" - ) - x_pos += 2.5 - else: - x_pos += 2.1 - - # Final output - draw_arrow(ax, x_pos + 0.3, 3.1, x_pos + 0.9, 3.1, lw=1.5) - draw_box( - ax, - x_pos + 0.5, - 2.5, - 1.3, - 1.2, - "~50\nTWARZE\n✓", - fill=GRAY2, - fontsize=FS, - fontweight="bold", - ) - - # Timing info - ax.text( - 5.0, - -0.5, - "Czas: 99% okien odrzucone w etapach 1-3 (~5 μs każde)\n" - "Tylko 0.01% dochodzi do etapu 25 → cały obraz w ~30 ms = 30+ fps", - ha="center", - fontsize=FS, - style="italic", - bbox={"boxstyle": "round,pad=0.4", "facecolor": GRAY4, "edgecolor": GRAY3}, - ) - - save_fig(fig, "q24_viola_jones_cascade.png") - - -# ============================================================ -# 4. Haar Features -# ============================================================ -def draw_haar_features() -> None: - """Draw haar features.""" - fig, axes = plt.subplots(1, 4, figsize=(11, 3)) - fig.suptitle( - "Cechy Haar — typy i zastosowanie na twarzy", - fontsize=FS_TITLE, - fontweight="bold", - y=1.02, - ) - - # Feature 1: Vertical edge - ax = axes[0] - ax.add_patch( - mpatches.Rectangle((0, 0), 1, 2, facecolor=GRAY4, edgecolor=LN, lw=1.5) - ) - ax.add_patch( - mpatches.Rectangle((1, 0), 1, 2, facecolor=GRAY3, edgecolor=LN, lw=1.5) - ) - ax.text( - 0.5, 1, "+Σ₁", ha="center", va="center", fontsize=FS_LABEL, fontweight="bold" - ) - ax.text( - 1.5, 1, "-Σ₂", ha="center", va="center", fontsize=FS_LABEL, fontweight="bold" - ) - ax.set_xlim(-0.2, 2.2) - ax.set_ylim(-0.5, 2.5) - ax.set_aspect("equal") - ax.axis("off") - ax.set_title("Krawędź pionowa\nwartość = Σ₁ - Σ₂", fontsize=FS) - - # Feature 2: Horizontal edge - ax = axes[1] - ax.add_patch( - mpatches.Rectangle((0, 1), 2, 1, facecolor=GRAY4, edgecolor=LN, lw=1.5) - ) - ax.add_patch( - mpatches.Rectangle((0, 0), 2, 1, facecolor=GRAY3, edgecolor=LN, lw=1.5) - ) - ax.text( - 1, 1.5, "+Σ₁", ha="center", va="center", fontsize=FS_LABEL, fontweight="bold" - ) - ax.text( - 1, 0.5, "-Σ₂", ha="center", va="center", fontsize=FS_LABEL, fontweight="bold" - ) - ax.set_xlim(-0.2, 2.2) - ax.set_ylim(-0.5, 2.5) - ax.set_aspect("equal") - ax.axis("off") - ax.set_title("Krawędź pozioma\n(oczy vs czoło)", fontsize=FS) - - # Feature 3: Three-rectangle (line) - ax = axes[2] - ax.add_patch( - mpatches.Rectangle((0, 0), 0.7, 2, facecolor=GRAY3, edgecolor=LN, lw=1.5) - ) - ax.add_patch( - mpatches.Rectangle((0.7, 0), 0.7, 2, facecolor=GRAY4, edgecolor=LN, lw=1.5) - ) - ax.add_patch( - mpatches.Rectangle((1.4, 0), 0.7, 2, facecolor=GRAY3, edgecolor=LN, lw=1.5) - ) - ax.text( - 0.35, 1, "-Σ₁", ha="center", va="center", fontsize=FS_SMALL, fontweight="bold" - ) - ax.text( - 1.05, 1, "+Σ₂", ha="center", va="center", fontsize=FS_SMALL, fontweight="bold" - ) - ax.text( - 1.75, 1, "-Σ₃", ha="center", va="center", fontsize=FS_SMALL, fontweight="bold" - ) - ax.set_xlim(-0.2, 2.3) - ax.set_ylim(-0.5, 2.5) - ax.set_aspect("equal") - ax.axis("off") - ax.set_title("Linia (3 prostokąty)\n(nos vs policzki)", fontsize=FS) - - _draw_haar_face_panel(axes[3]) - - fig.tight_layout() - save_fig(fig, "q24_haar_features.png") - - -def _draw_haar_face_panel(ax: Axes) -> None: - """Draw Haar feature application on face schematic.""" - face = mpatches.Ellipse( - (1.2, 1.2), 2.0, 2.4, facecolor=GRAY4, edgecolor=LN, lw=1.5, - ) - ax.add_patch(face) - ax.add_patch( - mpatches.Ellipse((0.7, 1.6), 0.4, 0.2, facecolor=GRAY3, edgecolor=LN, lw=1) - ) - ax.add_patch( - mpatches.Ellipse((1.7, 1.6), 0.4, 0.2, facecolor=GRAY3, edgecolor=LN, lw=1) - ) - ax.plot([1.2, 1.1, 1.3], [1.3, 0.9, 0.9], color=LN, lw=1) - ax.plot([0.8, 1.0, 1.2, 1.4, 1.6], [0.55, 0.5, 0.55, 0.5, 0.55], color=LN, lw=1) - ax.add_patch( - mpatches.Rectangle( - (0.3, 1.4), 1.8, 0.4, facecolor="none", edgecolor=LN, lw=2, linestyle="--", - ) - ) - ax.annotate( - "cechy Haar\n(oczy ciemne\nvs czoło jasne)", - xy=(1.2, 1.85), - xytext=(2.2, 2.3), - fontsize=FS_SMALL, - ha="center", - arrowprops={"arrowstyle": "->", "color": LN, "lw": 1}, - ) - ax.set_xlim(-0.2, 3.0) - ax.set_ylim(-0.2, 2.8) - ax.set_aspect("equal") - ax.axis("off") - ax.set_title("Zastosowanie na twarzy", fontsize=FS) - - -# ============================================================ -# 5. Integral Image -# ============================================================ -def draw_integral_image() -> None: - """Draw integral image.""" - fig, axes = plt.subplots(1, 3, figsize=(11, 3.5)) - fig.suptitle( - "Integral Image — suma prostokąta w O(1)", - fontsize=FS_TITLE, - fontweight="bold", - y=1.02, - ) - - # Original image - ax = axes[0] - data = np.array([[1, 2, 3], [4, 5, 6], [7, 8, 9]]) - ax.imshow(data, cmap="gray", vmin=0, vmax=10) - for i in range(3): - for j in range(3): - ax.text( - j, - i, - str(data[i, j]), - ha="center", - va="center", - fontsize=12, - fontweight="bold", - color="white" if data[i, j] > _DATA_BRIGHT_THRESH else "black", - ) - ax.set_title("① Obraz oryginalny", fontsize=FS, fontweight="bold") - ax.set_xticks([]) - ax.set_yticks([]) - - # Integral image - ax = axes[1] - ii = np.array([[1, 3, 6], [5, 12, 21], [12, 27, 45]]) - ax.imshow(ii, cmap="gray", vmin=0, vmax=50) - for i in range(3): - for j in range(3): - ax.text( - j, - i, - str(ii[i, j]), - ha="center", - va="center", - fontsize=12, - fontweight="bold", - color="white" if ii[i, j] > _II_BRIGHT_THRESH else "black", - ) - ax.set_title("② Integral Image\n(sumy kumulatywne)", fontsize=FS, fontweight="bold") - ax.set_xticks([]) - ax.set_yticks([]) - - # Formula illustration - ax = axes[2] - ax.axis("off") - ax.set_xlim(0, 4) - ax.set_ylim(0, 4) - # Draw rectangle - ax.add_patch( - mpatches.Rectangle((0.5, 0.5), 3, 3, facecolor="white", edgecolor=LN, lw=1) - ) - ax.add_patch( - mpatches.Rectangle((1.5, 0.5), 2, 2, facecolor=GRAY3, edgecolor=LN, lw=2) - ) - # Labels - ax.text(0.3, 3.7, "A", fontsize=12, fontweight="bold") - ax.text(3.6, 3.7, "B", fontsize=12, fontweight="bold") - ax.text(0.3, 0.3, "C", fontsize=12, fontweight="bold") - ax.text(3.6, 0.3, "D", fontsize=12, fontweight="bold") - ax.text( - 2.5, - 1.5, - "SZUKANA\nSUMA", - ha="center", - va="center", - fontsize=FS, - fontweight="bold", - ) - ax.text( - 2.0, - -0.3, - "Suma = D - B - C + A\n= 4 odczyty → O(1) ZAWSZE!", - ha="center", - fontsize=FS, - fontweight="bold", - bbox={"boxstyle": "round,pad=0.3", "facecolor": GRAY4, "edgecolor": GRAY3}, - ) - ax.set_title( - "③ Formuła: 4 odczyty\n= O(1) niezależnie od rozmiaru", - fontsize=FS, - fontweight="bold", - ) - - fig.tight_layout() - save_fig(fig, "q24_integral_image.png") - - -# ============================================================ -# 6. R-CNN Evolution -# ============================================================ -def draw_rcnn_evolution() -> None: - """Draw rcnn evolution.""" - fig, ax = plt.subplots(figsize=(11, 7)) - ax.set_xlim(-0.5, 11) - ax.set_ylim(-0.5, 7.5) - ax.set_aspect("equal") - ax.axis("off") - ax.set_title( - "Ewolucja R-CNN: od 50s do 0.2s na obraz", - fontsize=FS_TITLE, - fontweight="bold", - pad=12, - ) - - y_positions = [5.5, 3.0, 0.5] - labels = [ - "R-CNN (2014) — 50 s/obraz", - "Fast R-CNN (2015) — 2 s/obraz", - "Faster R-CNN (2015) — 0.2 s/obraz", - ] - - # R-CNN - y = y_positions[0] - ax.text(0, y + 1.3, labels[0], fontsize=FS_LABEL, fontweight="bold") - draw_box(ax, 0, y, 2, 0.9, "Selective\nSearch", fill=GRAY2, fontsize=FS) - draw_arrow(ax, 2.1, y + 0.45, 2.5, y + 0.45) - ax.text(2.3, y + 0.8, "~2000", ha="center", fontsize=FS_SMALL, style="italic") - draw_box(ax, 2.6, y, 1.5, 0.9, "Resize\n224x224", fill=GRAY4, fontsize=FS) - draw_arrow(ax, 4.2, y + 0.45, 4.6, y + 0.45) - draw_box( - ax, 4.7, y, 1.5, 0.9, "CNN\nx2000!", fill=GRAY3, fontsize=FS, fontweight="bold" - ) - draw_arrow(ax, 6.3, y + 0.45, 6.7, y + 0.45) - draw_box(ax, 6.8, y, 1.3, 0.9, "SVM\nklasyf.", fill=GRAY4, fontsize=FS) - draw_arrow(ax, 8.2, y + 0.45, 8.6, y + 0.45) - draw_box(ax, 8.7, y, 1.0, 0.9, "NMS", fill=GRAY1, fontsize=FS) - # Problem annotation - ax.text( - 5.5, - y - 0.4, - "⚠ CNN uruchamiane 2000x → 50 sek!", - ha="center", - fontsize=FS_SMALL, - fontweight="bold", - bbox={"boxstyle": "round,pad=0.2", "facecolor": GRAY4, "edgecolor": GRAY3}, - ) - - # Fast R-CNN - y = y_positions[1] - ax.text(0, y + 1.3, labels[1], fontsize=FS_LABEL, fontweight="bold") - draw_box(ax, 0, y, 2, 0.9, "Selective\nSearch", fill=GRAY2, fontsize=FS) - draw_arrow(ax, 2.1, y + 0.45, 2.5, y + 0.45) - draw_box( - ax, - 2.6, - y, - 1.5, - 0.9, - "CNN\nx1 (RAZ!)", - fill=GRAY3, - fontsize=FS, - fontweight="bold", - ) - draw_arrow(ax, 4.2, y + 0.45, 4.6, y + 0.45) - draw_box( - ax, 4.7, y, 1.5, 0.9, "ROI\nPooling", fill=GRAY1, fontsize=FS, fontweight="bold" - ) - draw_arrow(ax, 6.3, y + 0.45, 6.7, y + 0.45) - draw_box(ax, 6.8, y, 1.3, 0.9, "FC\nklasa+bbox", fill=GRAY4, fontsize=FS) - draw_arrow(ax, 8.2, y + 0.45, 8.6, y + 0.45) - draw_box(ax, 8.7, y, 1.0, 0.9, "NMS", fill=GRAY1, fontsize=FS) - ax.text( - 3.8, - y - 0.4, - "✓ CNN RAZ na cały obraz → 25x szybciej", - ha="center", - fontsize=FS_SMALL, - fontweight="bold", - bbox={"boxstyle": "round,pad=0.2", "facecolor": GRAY4, "edgecolor": GRAY3}, - ) - - # Faster R-CNN - y = y_positions[2] - ax.text(0, y + 1.3, labels[2], fontsize=FS_LABEL, fontweight="bold") - draw_box( - ax, - 0.5, - y, - 1.5, - 0.9, - "CNN\nBackbone", - fill=GRAY3, - fontsize=FS, - fontweight="bold", - ) - draw_arrow(ax, 2.1, y + 0.45, 2.5, y + 0.45) - draw_box(ax, 2.6, y, 1.5, 0.9, "Feature\nMap", fill=GRAY1, fontsize=FS) - draw_arrow(ax, 4.2, y + 0.45, 4.6, y + 0.45) - draw_box( - ax, - 4.7, - y, - 1.3, - 0.9, - "RPN\n(w sieci!)", - fill=GRAY2, - fontsize=FS, - fontweight="bold", - ) - draw_arrow(ax, 6.1, y + 0.45, 6.5, y + 0.45) - draw_box(ax, 6.6, y, 1.3, 0.9, "ROI\nPooling", fill=GRAY1, fontsize=FS) - draw_arrow(ax, 8.0, y + 0.45, 8.4, y + 0.45) - draw_box(ax, 8.5, y, 1.3, 0.9, "FC\nklasa+bbox", fill=GRAY4, fontsize=FS) - ax.text( - 5.0, - y - 0.4, - "✓ RPN zastępuje Selective Search → end-to-end", - ha="center", - fontsize=FS_SMALL, - fontweight="bold", - bbox={"boxstyle": "round,pad=0.2", "facecolor": GRAY4, "edgecolor": GRAY3}, - ) - - save_fig(fig, "q24_rcnn_evolution.png") - - -def _draw_yolo_cell_prediction(ax: Axes) -> None: - """Draw YOLO cell prediction vector panel.""" - ax.axis("off") - ax.set_xlim(0, 6) - ax.set_ylim(-1, 5) - - labels = [ - "x", "y", "w", "h", "conf", - "x", "y", "w", "h", "conf", - "P(c₁)", "...", "P(c₂₀)", - ] - colors_vec = [GRAY4] * 5 + [GRAY2] * 5 + [GRAY1] * 3 - - bw = 0.42 - for i, (label, col) in enumerate( - zip(labels, colors_vec, strict=False), - ): - x_pos = 0.3 + i * bw - ax.add_patch( - mpatches.Rectangle( - (x_pos, 2.5), bw - 0.02, 0.6, - facecolor=col, edgecolor=LN, lw=0.8, - ) - ) - ax.text( - x_pos + bw / 2, - 2.8, - label, - ha="center", - va="center", - fontsize=5, - fontweight="bold", - ) - - ax.annotate( - "", xy=(0.3, 2.4), xytext=(2.4, 2.4), - arrowprops={"arrowstyle": "-", "lw": 1}, - ) - ax.text(1.35, 2.15, "bbox 1 (5 wartości)", ha="center", fontsize=FS_SMALL) - - ax.annotate( - "", xy=(2.4, 2.4), xytext=(4.5, 2.4), - arrowprops={"arrowstyle": "-", "lw": 1}, - ) - ax.text(3.45, 2.15, "bbox 2 (5 wartości)", ha="center", fontsize=FS_SMALL) - - ax.annotate( - "", xy=(4.5, 2.4), xytext=(5.8, 2.4), - arrowprops={"arrowstyle": "-", "lw": 1}, - ) - ax.text(5.15, 2.15, "20 klas", ha="center", fontsize=FS_SMALL) - - ax.text( - 3.0, - 3.5, - "Każda komórka → 30 wartości\n= 2x(x,y,w,h,conf) + 20 klas", - ha="center", - fontsize=FS, - fontweight="bold", - bbox={"boxstyle": "round,pad=0.3", "facecolor": GRAY4, "edgecolor": GRAY3}, - ) - - ax.set_title( - "② Predykcja jednej komórki\n(S=7, B=2, C=20)", - fontsize=FS, - fontweight="bold", - ) - - -# ============================================================ -# 7. YOLO Grid -# ============================================================ -def draw_yolo_grid() -> None: - """Draw yolo grid.""" - fig, axes = plt.subplots(1, 3, figsize=(12, 4)) - fig.suptitle( - "YOLO — detekcja jednoetapowa (siatka SxS)", - fontsize=FS_TITLE, - fontweight="bold", - y=1.02, - ) - - # Grid on image - ax = axes[0] - grid_size = 7 - ax.set_xlim(0, grid_size) - ax.set_ylim(0, grid_size) - for i in range(grid_size + 1): - ax.axhline(y=i, color=LN, lw=0.5, alpha=0.5) - ax.axvline(x=i, color=LN, lw=0.5, alpha=0.5) - ax.add_patch( - mpatches.Rectangle( - (0, 0), grid_size, grid_size, - facecolor=GRAY4, edgecolor=LN, lw=1.5, - ) - ) - # Highlight one cell - ax.add_patch(mpatches.Rectangle((3, 3), 1, 1, facecolor=GRAY2, edgecolor=LN, lw=2)) - # Object center dot - ax.plot(3.5, 3.5, "ko", markersize=8) - # Bounding box from that cell - ax.add_patch( - mpatches.Rectangle( - (2.0, 2.2), 3.0, 2.6, facecolor="none", edgecolor=LN, lw=2, linestyle="--" - ) - ) - ax.text( - 3.5, - 1.8, - "bbox z komórki (3,3)", - ha="center", - fontsize=FS_SMALL, - fontweight="bold", - ) - ax.set_aspect("equal") - ax.invert_yaxis() - ax.set_title("① Siatka 7x7\nna obrazie", fontsize=FS, fontweight="bold") - ax.set_xticks([]) - ax.set_yticks([]) - - _draw_yolo_cell_prediction(axes[1]) - - # Speed comparison - ax = axes[2] - ax.axis("off") - ax.set_xlim(0, 5) - ax.set_ylim(0, 5) - - methods = ["R-CNN", "Fast R-CNN", "Faster R-CNN", "YOLO", "YOLOv8"] - fps_vals = [0.02, 0.5, 5, 45, 100] - bar_colors = [GRAY3, GRAY3, GRAY3, GRAY2, GRAY1] - - for i, (m, f, c) in enumerate(zip(methods, fps_vals, bar_colors, strict=False)): - bar_w = f / 100 * 4.0 - y_pos = 4.0 - i * 0.8 - ax.add_patch( - mpatches.Rectangle( - (0.5, y_pos), max(bar_w, 0.1), 0.5, facecolor=c, edgecolor=LN, lw=0.8 - ) - ) - ax.text( - 0.4, - y_pos + 0.25, - m, - ha="right", - va="center", - fontsize=FS, - fontweight="bold", - ) - ax.text( - max(0.7, 0.5 + bar_w + 0.1), - y_pos + 0.25, - f"{f} fps", - ha="left", - va="center", - fontsize=FS, - ) - - ax.set_title( - "③ Porównanie szybkości\n(fps = klatki/sek)", fontsize=FS, fontweight="bold" - ) - - fig.tight_layout() - save_fig(fig, "q24_yolo_grid.png") - - -# ============================================================ -# 8. IoU Diagram -# ============================================================ -def draw_iou_diagram() -> None: - """Draw iou diagram.""" - fig, axes = plt.subplots(1, 3, figsize=(11, 3.5)) - fig.suptitle( - "IoU (Intersection over Union) — miara nakładania bboxów", - fontsize=FS_TITLE, - fontweight="bold", - y=1.02, - ) - - # Low IoU - ax = axes[0] - ax.add_patch( - mpatches.Rectangle( - (0, 0), 3, 3, facecolor=GRAY4, edgecolor=LN, lw=1.5, label="A" - ) - ) - ax.add_patch( - mpatches.Rectangle( - (2.5, 2.5), - 3, - 3, - facecolor=GRAY2, - edgecolor=LN, - lw=1.5, - alpha=0.7, - label="B", - ) - ) - # Intersection - ax.add_patch( - mpatches.Rectangle((2.5, 2.5), 0.5, 0.5, facecolor=GRAY3, edgecolor=LN, lw=2) - ) - ax.text(1.5, 1.5, "A", ha="center", va="center", fontsize=12, fontweight="bold") - ax.text(4, 4, "B", ha="center", va="center", fontsize=12, fontweight="bold") - ax.set_xlim(-0.5, 6) - ax.set_ylim(-0.5, 6) - ax.set_aspect("equal") - ax.axis("off") - ax.set_title( - "IoU ≈ 0.04\n(prawie się nie nakładają)", fontsize=FS, fontweight="bold" - ) - - # Medium IoU - ax = axes[1] - ax.add_patch( - mpatches.Rectangle((0, 0), 3, 3, facecolor=GRAY4, edgecolor=LN, lw=1.5) - ) - ax.add_patch( - mpatches.Rectangle( - (1.5, 1.5), 3, 3, facecolor=GRAY2, edgecolor=LN, lw=1.5, alpha=0.7 - ) - ) - ax.add_patch( - mpatches.Rectangle((1.5, 1.5), 1.5, 1.5, facecolor=GRAY3, edgecolor=LN, lw=2) - ) - ax.text(0.7, 0.7, "A", ha="center", va="center", fontsize=12, fontweight="bold") - ax.text(3.5, 3.5, "B", ha="center", va="center", fontsize=12, fontweight="bold") - ax.text(2.25, 2.25, "∩", ha="center", va="center", fontsize=14, fontweight="bold") - ax.set_xlim(-0.5, 5) - ax.set_ylim(-0.5, 5) - ax.set_aspect("equal") - ax.axis("off") - ax.set_title("IoU ≈ 0.14\n(częściowe nakładanie)", fontsize=FS, fontweight="bold") - - # High IoU - ax = axes[2] - ax.add_patch( - mpatches.Rectangle((0, 0), 3, 3, facecolor=GRAY4, edgecolor=LN, lw=1.5) - ) - ax.add_patch( - mpatches.Rectangle( - (0.3, 0.3), 3, 3, facecolor=GRAY2, edgecolor=LN, lw=1.5, alpha=0.7 - ) - ) - ax.add_patch( - mpatches.Rectangle((0.3, 0.3), 2.7, 2.7, facecolor=GRAY3, edgecolor=LN, lw=2) - ) - ax.text(-0.3, -0.3, "A", ha="center", va="center", fontsize=12, fontweight="bold") - ax.text(3.5, 3.5, "B", ha="center", va="center", fontsize=12, fontweight="bold") - ax.text(1.65, 1.65, "∩", ha="center", va="center", fontsize=14, fontweight="bold") - ax.set_xlim(-0.8, 4) - ax.set_ylim(-0.8, 4) - ax.set_aspect("equal") - ax.axis("off") - ax.set_title( - "IoU ≈ 0.74\n(duże nakładanie → duplikat!)", fontsize=FS, fontweight="bold" - ) - - fig.tight_layout() - save_fig(fig, "q24_iou_diagram.png") - - -# ============================================================ -# 9. NMS Step-by-Step -# ============================================================ -def draw_nms_steps() -> None: - """Draw nms steps.""" - fig, axes = plt.subplots(1, 3, figsize=(12, 4)) - fig.suptitle( - "NMS (Non-Maximum Suppression) — usuwanie duplikatów", - fontsize=FS_TITLE, - fontweight="bold", - y=1.02, - ) - - # Before NMS - ax = axes[0] - ax.add_patch(mpatches.Rectangle((0, 0), 6, 5, facecolor=GRAY4, edgecolor=LN, lw=1)) - # Multiple overlapping boxes for same object - ax.add_patch( - mpatches.Rectangle((1, 1), 2.5, 3, facecolor="none", edgecolor=LN, lw=2) - ) - ax.text(2.25, 4.2, "conf=0.95", ha="center", fontsize=FS_SMALL, fontweight="bold") - ax.add_patch( - mpatches.Rectangle( - (1.2, 1.3), 2.3, 2.8, facecolor="none", edgecolor=LN, lw=1.5, linestyle="--" - ) - ) - ax.text(2.35, 1.1, "conf=0.90", ha="center", fontsize=FS_SMALL) - ax.add_patch( - mpatches.Rectangle( - (0.8, 0.8), 2.7, 3.2, facecolor="none", edgecolor=LN, lw=1, linestyle=":" - ) - ) - ax.text(2.15, 0.6, "conf=0.85", ha="center", fontsize=FS_SMALL) - # Different object - ax.add_patch( - mpatches.Rectangle((4, 2), 1.5, 1.5, facecolor="none", edgecolor=LN, lw=1.5) - ) - ax.text(4.75, 3.7, "conf=0.80", ha="center", fontsize=FS_SMALL) - ax.text( - 2, - 0.2, - "⚠ 4 detekcje (3 duplikaty!)", - ha="center", - fontsize=FS_SMALL, - fontweight="bold", - ) - ax.set_xlim(-0.3, 6.3) - ax.set_ylim(-0.3, 5.3) - ax.set_aspect("equal") - ax.axis("off") - ax.set_title( - "① Przed NMS\n(wiele nakładających się)", fontsize=FS, fontweight="bold" - ) - - # NMS process - ax = axes[1] - ax.axis("off") - ax.set_xlim(0, 6) - ax.set_ylim(0, 5) - - steps = [ - ("1. Sortuj: [0.95, 0.90, 0.85, 0.80]", 4.5), - ("2. Weź najlepszą (0.95) → ZACHOWAJ", 3.7), - ("3. IoU(0.95, 0.90)=0.82 > 0.5 → USUŃ", 2.9), - ("4. IoU(0.95, 0.85)=0.75 > 0.5 → USUŃ", 2.1), - ("5. IoU(0.95, 0.80)=0.10 < 0.5 → ZACHOWAJ", 1.3), - ] - colors = [GRAY4, GRAY2, GRAY4, GRAY4, GRAY2] - for (text, yp), c in zip(steps, colors, strict=False): - ax.text( - 3.0, - yp, - text, - ha="center", - fontsize=FS, - bbox={"boxstyle": "round,pad=0.2", "facecolor": c, "edgecolor": GRAY3}, - ) - - ax.set_title("② Algorytm NMS\n(próg IoU = 0.5)", fontsize=FS, fontweight="bold") - - # After NMS - ax = axes[2] - ax.add_patch(mpatches.Rectangle((0, 0), 6, 5, facecolor=GRAY4, edgecolor=LN, lw=1)) - # Only best box for each object - ax.add_patch( - mpatches.Rectangle((1, 1), 2.5, 3, facecolor="none", edgecolor=LN, lw=2.5) - ) - ax.text(2.25, 4.2, "conf=0.95 ✓", ha="center", fontsize=FS_SMALL, fontweight="bold") - ax.add_patch( - mpatches.Rectangle((4, 2), 1.5, 1.5, facecolor="none", edgecolor=LN, lw=2.5) - ) - ax.text(4.75, 3.7, "conf=0.80 ✓", ha="center", fontsize=FS_SMALL, fontweight="bold") - ax.text( - 3, - 0.2, - "✓ 2 unikalne obiekty", - ha="center", - fontsize=FS_SMALL, - fontweight="bold", - ) - ax.set_xlim(-0.3, 6.3) - ax.set_ylim(-0.3, 5.3) - ax.set_aspect("equal") - ax.axis("off") - ax.set_title("③ Po NMS\n(1 bbox na obiekt)", fontsize=FS, fontweight="bold") - - fig.tight_layout() - save_fig(fig, "q24_nms_steps.png") - - -# ============================================================ -# 10. Detector from Classifier — 3 approaches -# ============================================================ -def draw_detector_from_classifier() -> None: - """Draw detector from classifier.""" - fig, ax = plt.subplots(figsize=(11, 9)) - ax.set_xlim(-0.5, 11) - ax.set_ylim(-1, 9.5) - ax.set_aspect("equal") - ax.axis("off") - ax.set_title( - "Jak zbudować detektor z klasyfikatora? — 3 podejścia", - fontsize=FS_TITLE, - fontweight="bold", - pad=12, - ) - - # ---- Approach 1: Sliding Window ---- - y = 7.0 - ax.text( - 0, - y + 1.5, - "① Sliding Window (NAJWOLNIEJSZE)", - fontsize=FS_LABEL, - fontweight="bold", - bbox={"boxstyle": "round,pad=0.2", "facecolor": GRAY4, "edgecolor": GRAY3}, - ) - - # Image with sliding window - ax.add_patch( - mpatches.Rectangle( - (0, y - 0.6), 1.8, 1.8, facecolor=GRAY1, edgecolor=LN, lw=1.5 - ) - ) - ax.text(0.9, y + 0.3, "obraz", ha="center", fontsize=FS_SMALL) - # Sliding windows - for dx, dy in [(0.1, 0.1), (0.4, 0.1), (0.7, 0.1), (0.1, 0.5), (0.4, 0.5)]: - ax.add_patch( - mpatches.Rectangle( - (dx, y - 0.5 + dy), - 0.5, - 0.5, - facecolor="none", - edgecolor=LN, - lw=0.8, - linestyle="--", - ) - ) - - draw_arrow(ax, 2.0, y + 0.3, 2.7, y + 0.3, lw=1.2) - ax.text(2.35, y + 0.6, "xmiliony", fontsize=FS_SMALL, style="italic") - - draw_box( - ax, - 2.8, - y - 0.3, - 1.8, - 1.2, - 'Klasyfikator\n(ResNet)\n"kot? pies? tło?"', - fill=GRAY4, - fontsize=FS, - ) - draw_arrow(ax, 4.7, y + 0.3, 5.3, y + 0.3, lw=1.2) - draw_box(ax, 5.4, y - 0.3, 1.2, 1.2, "NMS", fill=GRAY1, fontsize=FS) - draw_arrow(ax, 6.7, y + 0.3, 7.3, y + 0.3, lw=1.2) - ax.text( - 8.5, - y + 0.3, - "~3.3h / obraz!\n⚠ NIEPRAKTYCZNE", - ha="center", - fontsize=FS, - fontweight="bold", - bbox={"boxstyle": "round,pad=0.3", "facecolor": GRAY4, "edgecolor": GRAY3}, - ) - - # ---- Approach 2: Region Proposals ---- - y = 3.8 - ax.text( - 0, - y + 1.5, - "② Region Proposals + Klasyfikator (= R-CNN)", - fontsize=FS_LABEL, - fontweight="bold", - bbox={"boxstyle": "round,pad=0.2", "facecolor": GRAY4, "edgecolor": GRAY3}, - ) - - ax.add_patch( - mpatches.Rectangle( - (0, y - 0.6), 1.8, 1.8, facecolor=GRAY1, edgecolor=LN, lw=1.5 - ) - ) - ax.text(0.9, y + 0.3, "obraz", ha="center", fontsize=FS_SMALL) - # A few smart regions - ax.add_patch( - mpatches.Rectangle( - (0.1, y - 0.4), 0.7, 0.9, facecolor="none", edgecolor=LN, lw=1.5 - ) - ) - ax.add_patch( - mpatches.Rectangle( - (0.9, y + 0.0), 0.7, 0.6, facecolor="none", edgecolor=LN, lw=1.5 - ) - ) - - draw_arrow(ax, 2.0, y + 0.3, 2.7, y + 0.3, lw=1.2) - draw_box( - ax, - 2.8, - y - 0.3, - 1.6, - 1.2, - "Selective\nSearch\n~2000 regionów", - fill=GRAY2, - fontsize=FS, - ) - draw_arrow(ax, 4.5, y + 0.3, 5.1, y + 0.3, lw=1.2) - ax.text(4.8, y + 0.6, "x2000", fontsize=FS_SMALL, style="italic") - draw_box(ax, 5.2, y - 0.3, 1.5, 1.2, "Klasyfikator\n(CNN)", fill=GRAY4, fontsize=FS) - draw_arrow(ax, 6.8, y + 0.3, 7.4, y + 0.3, lw=1.2) - draw_box(ax, 7.5, y - 0.3, 1.0, 1.2, "NMS", fill=GRAY1, fontsize=FS) - draw_arrow(ax, 8.6, y + 0.3, 9.0, y + 0.3, lw=1.2) - ax.text( - 10.0, - y + 0.3, - "~20-50 s/obraz\n(250x szybciej)", - ha="center", - fontsize=FS, - fontweight="bold", - bbox={"boxstyle": "round,pad=0.3", "facecolor": GRAY4, "edgecolor": GRAY3}, - ) - - # ---- Approach 3: Fine-tune backbone ---- - y = 0.5 - ax.text( - 0, - y + 1.5, - "③ Fine-tune backbone + detection head (NAJLEPSZE)", - fontsize=FS_LABEL, - fontweight="bold", - bbox={"boxstyle": "round,pad=0.2", "facecolor": GRAY2, "edgecolor": GRAY3}, - ) - - ax.add_patch( - mpatches.Rectangle( - (0, y - 0.6), 1.8, 1.8, facecolor=GRAY1, edgecolor=LN, lw=1.5 - ) - ) - ax.text(0.9, y + 0.3, "obraz", ha="center", fontsize=FS_SMALL) - - draw_arrow(ax, 2.0, y + 0.3, 2.7, y + 0.3, lw=1.2) - draw_box( - ax, - 2.8, - y - 0.3, - 1.8, - 1.2, - "Pretrained\nbackbone\n(ResNet)", - fill=GRAY3, - fontsize=FS, - fontweight="bold", - ) - draw_arrow(ax, 4.7, y + 0.3, 5.3, y + 0.3, lw=1.2) - - # Two heads from feature map - draw_box(ax, 5.4, y + 0.3, 1.6, 0.6, "cls head\nP(klasa)", fill=GRAY4, fontsize=FS) - draw_box( - ax, 5.4, y - 0.5, 1.6, 0.6, "bbox head\nΔx,Δy,Δw,Δh", fill=GRAY4, fontsize=FS - ) - - draw_arrow(ax, 7.1, y + 0.6, 7.7, y + 0.6, lw=1.0) - draw_arrow(ax, 7.1, y - 0.2, 7.7, y - 0.2, lw=1.0) - draw_box(ax, 7.8, y - 0.3, 1.0, 1.2, "NMS", fill=GRAY1, fontsize=FS) - - draw_arrow(ax, 8.9, y + 0.3, 9.3, y + 0.3, lw=1.2) - ax.text( - 10.2, - y + 0.3, - "5-155 fps!\n✓ NAJLEPSZE", - ha="center", - fontsize=FS, - fontweight="bold", - bbox={"boxstyle": "round,pad=0.3", "facecolor": GRAY2, "edgecolor": GRAY3}, - ) - - save_fig(fig, "q24_detector_from_classifier.png") - - -# ============================================================ -# 11. SVM Hyperplane -# ============================================================ -def draw_svm_hyperplane() -> None: - """Draw svm hyperplane.""" - fig, ax = plt.subplots(figsize=(6, 5)) - ax.set_title( - "SVM — hiperpłaszczyzna i margines", - fontsize=FS_TITLE, - fontweight="bold", - pad=12, - ) - - x_pos = rng.standard_normal(15) * 0.5 + 3 - y_pos = rng.standard_normal(15) * 0.5 + 3 - ax.scatter( - x_pos, - y_pos, - marker="o", - s=50, - facecolors="white", - edgecolors=LN, - linewidths=1.5, - label="klasa +1 (pieszy)", - zorder=3, - ) - - x_neg = rng.standard_normal(15) * 0.5 + 1 - y_neg = rng.standard_normal(15) * 0.5 + 1 - ax.scatter( - x_neg, - y_neg, - marker="x", - s=50, - c=LN, - linewidths=1.5, - label="klasa -1 (tło)", - zorder=3, - ) - - # Hyperplane (decision boundary) - x_line = np.linspace(-0.5, 5, 100) - y_line = -x_line + 4.0 - ax.plot(x_line, y_line, "k-", lw=2, label="hiperpłaszczyzna") - - # Margin lines - ax.plot(x_line, y_line + 0.7, "k--", lw=1, alpha=0.5) - ax.plot(x_line, y_line - 0.7, "k--", lw=1, alpha=0.5) - - # Margin annotation - ax.annotate( - "", - xy=(2.5, 1.5 + 0.7), - xytext=(2.5, 1.5 - 0.7), - arrowprops={"arrowstyle": "<->", "color": LN, "lw": 1.5}, - ) - ax.text(2.8, 1.5, "margines\n(MAX!)", fontsize=FS, fontweight="bold") - - # Support vectors (highlight closest points) - # Find points closest to the line - ax.scatter( - [2.5], - [2.2], - marker="o", - s=120, - facecolors="none", - edgecolors=LN, - linewidths=2.5, - zorder=4, - ) - ax.scatter([1.5], [1.8], marker="x", s=120, c=LN, linewidths=2.5, zorder=4) - ax.annotate( - "support\nvectors", - xy=(1.5, 1.8), - xytext=(0.2, 3.0), - fontsize=FS, - fontweight="bold", - arrowprops={"arrowstyle": "->", "color": LN, "lw": 1}, - ) - - ax.set_xlim(-0.5, 5) - ax.set_ylim(-0.5, 5) - ax.set_xlabel("cecha 1 (np. gradient pionowy)", fontsize=FS) - ax.set_ylabel("cecha 2 (np. gradient poziomy)", fontsize=FS) - ax.legend(fontsize=FS_SMALL, loc="lower right") - ax.set_aspect("equal") - - save_fig(fig, "q24_svm_hyperplane.png") - - -# ============================================================ -# 12. Two-stage vs One-stage comparison table -# ============================================================ -def draw_two_vs_one_stage() -> None: - """Draw two vs one stage.""" - fig, ax = plt.subplots(figsize=(10, 3.5)) - ax.set_xlim(0, 10) - ax.set_ylim(-0.5, 4.5) - ax.set_aspect("equal") - ax.axis("off") - ax.set_title( - "Two-stage vs One-stage — porównanie", - fontsize=FS_TITLE, - fontweight="bold", - pad=8, - ) - - headers = ["Cecha", "Two-stage\n(Faster R-CNN)", "One-stage\n(YOLO)"] - rows = [ - ["Szybkość", "~5 fps", "45-155 fps"], - ["Dokładność (mAP)", "wyższa (historycznie)", "dorównuje (YOLOv8)"], - ["Małe obiekty", "lepszy", "gorszy (SSD/FPN pomaga)"], - ["Architektura", "2 etapy + NMS", "1 etap + NMS"], - ["Real-time?", "NIE", "TAK"], - ] - col_widths = [2.5, 3.5, 3.5] - draw_table( - ax, - headers, - rows, - 0.2, - 3.8, - col_widths, - row_h=0.65, - fontsize=FS, - header_fontsize=FS, - ) - - save_fig(fig, "q24_two_vs_one_stage.png") - - -# ============================================================ -# 13. ROI Pooling illustration -# ============================================================ -def draw_roi_pooling() -> None: - """Draw roi pooling.""" - fig, axes = plt.subplots(1, 3, figsize=(12, 4)) - fig.suptitle( - "ROI Pooling — dowolny rozmiar → stały rozmiar", - fontsize=FS_TITLE, - fontweight="bold", - y=1.02, - ) - - # Feature map with ROI - ax = axes[0] - # Draw feature map grid - fm = rng.integers(0, 10, (8, 8)) - ax.imshow(fm, cmap="gray", vmin=0, vmax=10, alpha=0.3) - for i in range(9): - ax.axhline(y=i - 0.5, color=LN, lw=0.3) - ax.axvline(x=i - 0.5, color=LN, lw=0.3) - # ROI rectangle - ax.add_patch( - mpatches.Rectangle( - (1.5, 1.5), 4, 4, facecolor="none", edgecolor=LN, lw=3, linestyle="-" - ) - ) - ax.text(3.5, 0.8, "ROI", ha="center", fontsize=FS_LABEL, fontweight="bold") - ax.set_xlim(-0.5, 7.5) - ax.set_ylim(7.5, -0.5) - ax.set_title("① Feature map\nz zaznaczonym ROI", fontsize=FS, fontweight="bold") - ax.set_xticks([]) - ax.set_yticks([]) - - # ROI divided into grid - ax = axes[1] - roi_data = np.array( - [ - [1, 3, 2, 1], - [0, 5, 1, 6], - [0, 4, 1, 0], - [7, 2, 9, 1], - ] - ) - ax.imshow(roi_data, cmap="gray", vmin=0, vmax=10) - for i in range(5): - ax.axhline(y=i - 0.5, color=LN, lw=1) - ax.axvline(x=i - 0.5, color=LN, lw=1) - # Grid lines for 2x2 pooling - ax.axhline(y=0.5, color=LN, lw=3, linestyle="--") - ax.axvline(x=0.5, color=LN, lw=3, linestyle="--") - for i in range(4): - for j in range(4): - ax.text( - j, - i, - str(roi_data[i, j]), - ha="center", - va="center", - fontsize=10, - fontweight="bold", - color="white" if roi_data[i, j] > _DATA_BRIGHT_THRESH else "black", - ) - ax.set_title("② ROI podzielony\nna siatkę 2x2", fontsize=FS, fontweight="bold") - ax.set_xticks([]) - ax.set_yticks([]) - - # Output after pooling - ax = axes[2] - out = np.array([[5, 6], [7, 9]]) - ax.imshow(out, cmap="gray", vmin=0, vmax=10) - for i in range(3): - ax.axhline(y=i - 0.5, color=LN, lw=1.5) - ax.axvline(x=i - 0.5, color=LN, lw=1.5) - for i in range(2): - for j in range(2): - ax.text( - j, - i, - str(out[i, j]), - ha="center", - va="center", - fontsize=14, - fontweight="bold", - color="white" if out[i, j] > _DATA_BRIGHT_THRESH else "black", - ) - ax.set_title( - "③ Po ROI Pool 2x2\n(max z każdej komórki)", fontsize=FS, fontweight="bold" - ) - ax.set_xticks([]) - ax.set_yticks([]) - - fig.tight_layout() - save_fig(fig, "q24_roi_pooling.png") - - -# ============================================================ -# 14. DETR Pipeline -# ============================================================ -def draw_detr_pipeline() -> None: - """Draw detr pipeline.""" - fig, ax = plt.subplots(figsize=(11, 4.5)) - ax.set_xlim(-0.5, 11.5) - ax.set_ylim(-1, 4.5) - ax.set_aspect("equal") - ax.axis("off") - ax.set_title( - "DETR — Transformer do detekcji (bez NMS, bez anchorów)", - fontsize=FS_TITLE, - fontweight="bold", - pad=12, - ) - - # Pipeline - draw_box(ax, 0, 1.5, 1.5, 1.5, "Obraz\nwejściowy", fill=GRAY1, fontsize=FS) - draw_arrow(ax, 1.6, 2.25, 2.1, 2.25, lw=1.5) - - draw_box( - ax, - 2.2, - 1.5, - 1.5, - 1.5, - "CNN\nBackbone\n(ResNet)", - fill=GRAY3, - fontsize=FS, - fontweight="bold", - ) - draw_arrow(ax, 3.8, 2.25, 4.3, 2.25, lw=1.5) - - draw_box( - ax, - 4.4, - 1.5, - 1.8, - 1.5, - "Transformer\nEncoder\n(self-attention)", - fill=GRAY2, - fontsize=FS, - ) - draw_arrow(ax, 6.3, 2.25, 6.8, 2.25, lw=1.5) - - draw_box( - ax, - 6.9, - 1.5, - 1.8, - 1.5, - "Transformer\nDecoder\n(N=100 queries)", - fill=GRAY2, - fontsize=FS, - fontweight="bold", - ) - - # Output branches - draw_arrow(ax, 8.8, 2.5, 9.5, 3.0, lw=1.2) - draw_box(ax, 9.6, 2.7, 1.5, 0.7, "klasa₁...klasa₁₀₀", fill=GRAY4, fontsize=FS_SMALL) - - draw_arrow(ax, 8.8, 2.0, 9.5, 1.5, lw=1.2) - draw_box(ax, 9.6, 1.2, 1.5, 0.7, "bbox₁...bbox₁₀₀", fill=GRAY4, fontsize=FS_SMALL) - - # Annotations - ax.text( - 7.8, - 0.5, - '100 object queries → 5 obiektów + 95x "brak"', - ha="center", - fontsize=FS, - style="italic", - bbox={"boxstyle": "round,pad=0.3", "facecolor": GRAY4, "edgecolor": GRAY3}, - ) - - ax.text( - 5.5, - 0.0, - "Hungarian matching (trening): optymalne dopasowanie predykcji do GT", - ha="center", - fontsize=FS_SMALL, - style="italic", - bbox={"boxstyle": "round,pad=0.2", "facecolor": GRAY4, "edgecolor": GRAY5}, - ) - - # Big benefit box - ax.text( - 5.5, - 4.0, - "BEZ anchorów • BEZ NMS • end-to-end • prosty pipeline", - ha="center", - fontsize=FS, - fontweight="bold", - bbox={"boxstyle": "round,pad=0.3", "facecolor": GRAY2, "edgecolor": GRAY3}, - ) - - save_fig(fig, "q24_detr_pipeline.png") - - -# ============================================================ -# 15. Sliding Window illustration -# ============================================================ -def draw_sliding_window() -> None: - """Draw sliding window.""" - fig, axes = plt.subplots(1, 3, figsize=(12, 4)) - fig.suptitle( - "Sliding Window — najprostsze podejście do detekcji", - fontsize=FS_TITLE, - fontweight="bold", - y=1.02, - ) - - # Multi-position - ax = axes[0] - ax.add_patch( - mpatches.Rectangle((0, 0), 8, 6, facecolor=GRAY4, edgecolor=LN, lw=1.5) - ) - # Grid of sliding windows - for i in range(4): - for j in range(3): - ax.add_patch( - mpatches.Rectangle( - (i * 1.8 + 0.2, j * 1.8 + 0.2), - 1.5, - 1.5, - facecolor="none", - edgecolor=LN, - lw=0.6, - linestyle="--", - ) - ) - # Highlight current window - ax.add_patch( - mpatches.Rectangle((2.0, 2.0), 1.5, 1.5, facecolor="none", edgecolor=LN, lw=2.5) - ) - ax.set_xlim(-0.5, 8.5) - ax.set_ylim(-0.5, 6.5) - ax.set_aspect("equal") - ax.axis("off") - ax.set_title("① Wiele pozycji\n(krok co 8 px)", fontsize=FS, fontweight="bold") - - # Multi-scale - ax = axes[1] - ax.add_patch( - mpatches.Rectangle((0, 0), 6, 5, facecolor=GRAY4, edgecolor=LN, lw=1.5) - ) - sizes = [(0.8, 0.8), (1.5, 1.5), (2.5, 2.5), (3.5, 3.5)] - for i, (w, h) in enumerate(sizes): - ax.add_patch( - mpatches.Rectangle( - (0.3 + i * 0.3, 0.3 + i * 0.3), - w, - h, - facecolor="none", - edgecolor=LN, - lw=1 + i * 0.3, - linestyle=[":", "--", "-.", "-"][i], - ) - ) - ax.text(3, 0, "4+ skal", ha="center", fontsize=FS_SMALL, fontweight="bold") - ax.set_xlim(-0.5, 6.5) - ax.set_ylim(-0.5, 5.5) - ax.set_aspect("equal") - ax.axis("off") - ax.set_title( - "② Wiele skal\n(obiekty mają różne rozmiary)", fontsize=FS, fontweight="bold" - ) - - # Count - ax = axes[2] - ax.axis("off") - ax.set_xlim(0, 6) - ax.set_ylim(0, 5) - - lines = [ - ("Obraz: 640 x 480 px", 4.5), - ("Okno: 64 x 64 px, krok 8 px", 3.8), - ("Pozycje: ~72 x 52 = 3 744", 3.1), - ("x 5 skal = 18 720 okien", 2.4), - ("x klasyfikacja = WOLNE!", 1.7), - ("→ ~3h na jeden obraz", 0.8), - ] - for text, yp in lines: - fw = "bold" if "~3h" in text or "WOLNE" in text else "normal" - col = GRAY2 if "WOLNE" in text or "~3h" in text else GRAY4 - ax.text( - 3.0, - yp, - text, - ha="center", - fontsize=FS, - fontweight=fw, - bbox={"boxstyle": "round,pad=0.2", "facecolor": col, "edgecolor": GRAY3}, - ) - - ax.set_title( - "③ Dlaczego wolne?\n(miliony klasyfikacji)", fontsize=FS, fontweight="bold" - ) - - fig.tight_layout() - save_fig(fig, "q24_sliding_window.png") - - -# ============================================================ -# 16. FPN (Feature Pyramid Network) -# ============================================================ -def draw_fpn() -> None: - """Draw fpn.""" - fig, ax = plt.subplots(figsize=(9, 5)) - ax.set_xlim(-0.5, 9.5) - ax.set_ylim(-0.5, 5.5) - ax.set_aspect("equal") - ax.axis("off") - ax.set_title( - "FPN (Feature Pyramid Network) — detekcja obiektów wszystkich rozmiarów", - fontsize=FS_TITLE, - fontweight="bold", - pad=12, - ) - - levels = [ - (0, 0, 2.0, 2.0, "C2\n56x56", "duże\ndetale"), - (0, 2.2, 1.5, 1.5, "C3\n28x28", ""), - (0, 3.9, 1.0, 1.0, "C4\n14x14", ""), - (0, 5.1, 0.6, 0.6, "C5\n7x7", "kontekst"), - ] - - for x, y, w, h, label, note in levels: - ax.add_patch( - mpatches.Rectangle((x, y - h), w, h, facecolor=GRAY4, edgecolor=LN, lw=1.5) - ) - ax.text( - x + w / 2, - y - h / 2, - label, - ha="center", - va="center", - fontsize=FS_SMALL, - fontweight="bold", - ) - if note: - ax.text( - x + w + 0.15, - y - h / 2, - note, - ha="left", - va="center", - fontsize=5, - style="italic", - ) - - ax.text( - 1.0, -0.3, "Bottom-up\n(backbone)", ha="center", fontsize=FS, fontweight="bold" - ) - - # Top-down + lateral - td_levels = [ - (4.5, 5.1, 0.6, 0.6, "P5"), - (4.5, 3.9, 1.0, 1.0, "P4"), - (4.5, 2.2, 1.5, 1.5, "P3"), - (4.5, 0, 2.0, 2.0, "P2"), - ] - - for x, y, w, h, label in td_levels: - ax.add_patch( - mpatches.Rectangle( - (x, y - h + h), w, h, facecolor=GRAY2, edgecolor=LN, lw=1.5 - ) - ) - ax.text( - x + w / 2, - y - h / 2 + h, - label, - ha="center", - va="center", - fontsize=FS_SMALL, - fontweight="bold", - ) - - # Lateral connections - for (_, y1, w1, h1, _, _), (x2, y2, _w2, h2, _) in zip( - levels, td_levels, strict=False - ): - draw_arrow(ax, w1 + 0.2, y1 - h1 / 2, x2 - 0.1, y2 + h2 / 2, lw=1, style="->") - - # Top-down arrows - for i in range(len(td_levels) - 1): - x2, y2, w2, h2, _ = td_levels[i] - x3, y3, w3, h3, _ = td_levels[i + 1] - draw_arrow( - ax, - x2 + w2 / 2, - y2, - x3 + w3 / 2, - y3 + h3 + 0.1, - lw=1.2, - style="->", - color=GRAY3, - ) - - ax.text( - 5.5, - -0.3, - "Top-down + lateral\n(FPN)", - ha="center", - fontsize=FS, - fontweight="bold", - ) - - # Detection outputs - det_labels = ["małe obj.", "średnie", "duże", "b. duże"] - for i, (x, y, w, h, _label) in enumerate(td_levels): - draw_arrow(ax, x + w + 0.1, y + h / 2, 7.5, y + h / 2, lw=0.8) - ax.text( - 7.7, - y + h / 2, - f"detekcja:\n{det_labels[3 - i]}", - fontsize=FS_SMALL, - va="center", - ) - - save_fig(fig, "q24_fpn.png") - - -# ============================================================ -# 17. Anchor boxes -# ============================================================ -def draw_anchor_boxes() -> None: - """Draw anchor boxes.""" - fig, ax = plt.subplots(figsize=(7, 5)) - ax.set_title( - "Anchor boxes — predefiniowane kształty", - fontsize=FS_TITLE, - fontweight="bold", - pad=12, - ) - - ax.add_patch(mpatches.Rectangle((0, 0), 6, 5, facecolor=GRAY4, edgecolor=LN, lw=1)) - - # Center point - cx, cy = 3, 2.5 - ax.plot(cx, cy, "ko", markersize=8, zorder=5) - ax.text(cx + 0.15, cy + 0.15, "(x, y)", fontsize=FS, fontweight="bold") - - # 9 anchors: 3 sizes x 3 ratios - anchors = [ - (0.8, 0.8, "-", "1:1 small"), - (1.6, 1.6, "-", "1:1 medium"), - (2.4, 2.4, "-", "1:1 large"), - (0.6, 1.2, "--", "1:2 small"), - (1.2, 2.4, "--", "1:2 medium"), - (1.8, 3.6, "--", "1:2 large"), - (1.2, 0.6, ":", "2:1 small"), - (2.4, 1.2, ":", "2:1 medium"), - (3.6, 1.8, ":", "2:1 large"), - ] - - for w, h, ls, _label in anchors: - rect = mpatches.Rectangle( - (cx - w / 2, cy - h / 2), - w, - h, - facecolor="none", - edgecolor=LN, - lw=1.2, - linestyle=ls, - ) - ax.add_patch(rect) - - # Legend-style labels - ax.text( - 3, - -0.5, - "9 anchorów = 3 rozmiary x 3 proporcje (1:1, 1:2, 2:1)\n" - "Sieć predykuje PRZESUNIĘCIE od najbliższego anchora", - ha="center", - fontsize=FS, - style="italic", - bbox={"boxstyle": "round,pad=0.3", "facecolor": GRAY4, "edgecolor": GRAY3}, - ) - - ax.set_xlim(-0.5, 6.5) - ax.set_ylim(-1.2, 5.5) - ax.set_aspect("equal") - ax.axis("off") - - save_fig(fig, "q24_anchor_boxes.png") - - -# ============================================================ -# 18. Detection task comparison -# ============================================================ -def draw_detection_tasks() -> None: - """Draw detection tasks.""" - fig, axes = plt.subplots(1, 3, figsize=(12, 4)) - fig.suptitle( - "Klasyfikacja vs Detekcja vs Segmentacja", - fontsize=FS_TITLE, - fontweight="bold", - y=1.02, - ) - - # Classification - ax = axes[0] - ax.add_patch( - mpatches.Rectangle((0, 0), 4, 4, facecolor=GRAY4, edgecolor=LN, lw=1.5) - ) - # Simple cat silhouette - ax.add_patch(mpatches.Ellipse((2, 2), 2, 1.5, facecolor=GRAY3, edgecolor=LN, lw=1)) - ax.add_patch(mpatches.Ellipse((2, 3), 1, 0.8, facecolor=GRAY3, edgecolor=LN, lw=1)) - # Ears - ax.plot([1.6, 1.5, 1.8], [3.3, 3.8, 3.4], color=LN, lw=1.5) - ax.plot([2.2, 2.5, 2.4], [3.3, 3.8, 3.4], color=LN, lw=1.5) - ax.text( - 2, -0.4, '→ "KOT" (jedna etykieta)', ha="center", fontsize=FS, fontweight="bold" - ) - ax.set_xlim(-0.5, 4.5) - ax.set_ylim(-0.8, 4.5) - ax.set_aspect("equal") - ax.axis("off") - ax.set_title("Klasyfikacja\n(co?)", fontsize=FS, fontweight="bold") - - # Detection - ax = axes[1] - ax.add_patch( - mpatches.Rectangle((0, 0), 4, 4, facecolor=GRAY4, edgecolor=LN, lw=1.5) - ) - # Cat - ax.add_patch( - mpatches.Ellipse((1.2, 2), 1.2, 1, facecolor=GRAY3, edgecolor=LN, lw=1) - ) - ax.add_patch( - mpatches.Ellipse((1.2, 2.8), 0.7, 0.5, facecolor=GRAY3, edgecolor=LN, lw=1) - ) - # Dog - ax.add_patch( - mpatches.Ellipse((3, 1.5), 1.2, 1, facecolor=GRAY2, edgecolor=LN, lw=1) - ) - ax.add_patch( - mpatches.Ellipse((3, 2.3), 0.7, 0.5, facecolor=GRAY2, edgecolor=LN, lw=1) - ) - # Bounding boxes - ax.add_patch( - mpatches.Rectangle((0.3, 1.2), 1.8, 2.2, facecolor="none", edgecolor=LN, lw=2.5) - ) - ax.text(1.2, 3.5, "KOT", ha="center", fontsize=FS_SMALL, fontweight="bold") - ax.add_patch( - mpatches.Rectangle((2.1, 0.8), 1.7, 2.0, facecolor="none", edgecolor=LN, lw=2.5) - ) - ax.text(3.0, 2.9, "PIES", ha="center", fontsize=FS_SMALL, fontweight="bold") - ax.text( - 2, - -0.4, - "→ bbox + klasa (N obiektów)", - ha="center", - fontsize=FS, - fontweight="bold", - ) - ax.set_xlim(-0.5, 4.5) - ax.set_ylim(-0.8, 4.5) - ax.set_aspect("equal") - ax.axis("off") - ax.set_title("Detekcja\n(co? + gdzie?)", fontsize=FS, fontweight="bold") - - # Segmentation - ax = axes[2] - ax.add_patch( - mpatches.Rectangle((0, 0), 4, 4, facecolor=GRAY4, edgecolor=LN, lw=1.5) - ) - # Cat mask (detailed) - theta = np.linspace(0, 2 * np.pi, 30) - cat_x = 1.2 + 0.6 * np.cos(theta) + 0.1 * np.sin(3 * theta) - cat_y = 2 + 0.5 * np.sin(theta) + 0.1 * np.cos(2 * theta) - ax.fill(cat_x, cat_y, facecolor=GRAY3, edgecolor=LN, lw=1.5) - # Dog mask - dog_x = 3.0 + 0.6 * np.cos(theta) + 0.05 * np.sin(4 * theta) - dog_y = 1.5 + 0.5 * np.sin(theta) + 0.08 * np.cos(3 * theta) - ax.fill(dog_x, dog_y, facecolor=GRAY2, edgecolor=LN, lw=1.5) - ax.text(1.2, 2, "KOT", ha="center", fontsize=FS_SMALL, fontweight="bold") - ax.text(3.0, 1.5, "PIES", ha="center", fontsize=FS_SMALL, fontweight="bold") - ax.text( - 2, - -0.4, - "→ maska pikseli (per piksel)", - ha="center", - fontsize=FS, - fontweight="bold", - ) - ax.set_xlim(-0.5, 4.5) - ax.set_ylim(-0.8, 4.5) - ax.set_aspect("equal") - ax.axis("off") - ax.set_title("Segmentacja\n(dokładna maska)", fontsize=FS, fontweight="bold") - - fig.tight_layout() - save_fig(fig, "q24_detection_tasks.png") - - -# ============================================================ -# 19. CNN Architecture overview -# ============================================================ -def draw_cnn_architecture() -> None: - """Draw cnn architecture.""" - fig, ax = plt.subplots(figsize=(12, 4)) - ax.set_xlim(-0.5, 12.5) - ax.set_ylim(-1, 4.5) - ax.set_aspect("equal") - ax.axis("off") - ax.set_title( - "CNN — od obrazu do predykcji (architektura)", - fontsize=FS_TITLE, - fontweight="bold", - pad=12, - ) - - # Input image - draw_box(ax, 0, 0.5, 1.5, 3, "Obraz\n224x224x3", fill=GRAY1, fontsize=FS) - - # Conv1 - draw_arrow(ax, 1.6, 2.0, 2.1, 2.0, lw=1.2) - draw_box( - ax, 2.2, 0.8, 1.2, 2.4, "Conv1\n+ReLU\n55x55x96", fill=GRAY4, fontsize=FS_SMALL - ) - - # Pool1 - draw_arrow(ax, 3.5, 2.0, 3.9, 2.0, lw=1.2) - draw_box(ax, 4.0, 1.0, 1.0, 2.0, "Pool\n27x27\nx96", fill=GRAY2, fontsize=FS_SMALL) - - # Conv2 - draw_arrow(ax, 5.1, 2.0, 5.5, 2.0, lw=1.2) - draw_box( - ax, - 5.6, - 0.8, - 1.2, - 2.4, - "Conv2\n+ReLU\n27x27\nx256", - fill=GRAY4, - fontsize=FS_SMALL, - ) - - # Pool2 - draw_arrow(ax, 6.9, 2.0, 7.3, 2.0, lw=1.2) - draw_box(ax, 7.4, 1.2, 0.8, 1.6, "Pool\n13x13\nx256", fill=GRAY2, fontsize=FS_SMALL) - - # More conv... - draw_arrow(ax, 8.3, 2.0, 8.7, 2.0, lw=1.2) - ax.text(9.0, 2.0, "...", fontsize=14, ha="center", va="center") - draw_arrow(ax, 9.3, 2.0, 9.7, 2.0, lw=1.2) - - # FC - draw_box(ax, 9.8, 1.2, 1.0, 1.6, "FC\n4096", fill=GRAY3, fontsize=FS) - - draw_arrow(ax, 10.9, 2.0, 11.3, 2.0, lw=1.2) - - # Output - draw_box( - ax, 11.4, 1.5, 1.0, 1.0, "Softmax\n1000 klas", fill=GRAY1, fontsize=FS_SMALL - ) - - # Annotations below - ax.text( - 3.0, - 0.0, - "rozmiar maleje\n224→55→27→13→6", - ha="center", - fontsize=FS_SMALL, - style="italic", - ) - ax.text( - 6.0, - 0.0, - "kanały rosną\n3→96→256→384", - ha="center", - fontsize=FS_SMALL, - style="italic", - ) - ax.text( - 10.0, 0.0, "decyzja\nkońcowa", ha="center", fontsize=FS_SMALL, style="italic" - ) - - # hierarchy - ax.text( - 6.0, - 4.0, - "Hierarchia: krawędzie → rogi → fragmenty → obiekty (K-R-F-O)", - ha="center", - fontsize=FS, - fontweight="bold", - bbox={"boxstyle": "round,pad=0.3", "facecolor": GRAY4, "edgecolor": GRAY3}, - ) - - save_fig(fig, "q24_cnn_architecture.png") - - # ============================================================ # MAIN # ============================================================ @@ -2294,4 +92,4 @@ if __name__ == "__main__": draw_anchor_boxes() draw_detection_tasks() draw_cnn_architecture() - _logger.info("All PYTANIE 24 diagrams generated!") + _logger.info("All PYTANIE 24 diagrams saved to: %s", OUTPUT_DIR) diff --git a/python_pkg/praca_magisterska_video/generate_images/generate_q31_diagrams.py b/python_pkg/praca_magisterska_video/generate_images/generate_q31_diagrams.py index bbf0133..fb006c7 100755 --- a/python_pkg/praca_magisterska_video/generate_images/generate_q31_diagrams.py +++ b/python_pkg/praca_magisterska_video/generate_images/generate_q31_diagrams.py @@ -17,1201 +17,39 @@ All: A4-compatible, B&W, 300 DPI, laser-printer-friendly. from __future__ import annotations import logging -from typing import TYPE_CHECKING import matplotlib as mpl mpl.use("Agg") -from pathlib import Path -import matplotlib.patches as mpatches -from matplotlib.patches import FancyBboxPatch -import matplotlib.pyplot as plt -import numpy as np +from python_pkg.praca_magisterska_video.generate_images._q31_common import ( + OUTPUT_DIR, + _logger, +) +from python_pkg.praca_magisterska_video.generate_images._q31_criteria_comparison import ( + draw_criteria_comparison, +) +from python_pkg.praca_magisterska_video.generate_images._q31_ev_spectrum import ( + draw_conditions_spectrum, + draw_expected_value, +) +from python_pkg.praca_magisterska_video.generate_images._q31_hurwicz_mnemonic import ( + draw_criteria_mnemonic, + draw_hurwicz_interpolation, +) +from python_pkg.praca_magisterska_video.generate_images._q31_regret_matrix import ( + draw_regret_matrix, +) + +__all__ = [ + "draw_conditions_spectrum", + "draw_criteria_comparison", + "draw_criteria_mnemonic", + "draw_expected_value", + "draw_hurwicz_interpolation", + "draw_regret_matrix", +] -if TYPE_CHECKING: - from matplotlib.axes import Axes - -_logger = logging.getLogger(__name__) - -DPI = 300 -BG = "white" -LN = "black" -FS = 8 -FS_TITLE = 11 -OUTPUT_DIR = str(Path(__file__).resolve().parent / "img") -Path(OUTPUT_DIR).mkdir(parents=True, exist_ok=True) - -GRAY1 = "#E8E8E8" -GRAY2 = "#D0D0D0" -GRAY3 = "#B8B8B8" -GRAY4 = "#F5F5F5" -GRAY5 = "#C0C0C0" - -# Number of regret table header columns before the max-regret column -_REGRET_HEADER_COLS = 4 -# Number of data state columns -_DATA_STATE_COLS = 3 -# Expected-value for the winning alternative -_WINNING_EV = 95 - - -def draw_box( - ax: Axes, - x: float, - y: float, - w: float, - h: float, - text: str, - *, - fill: str = "white", - lw: float = 1.2, - fontsize: float = FS, - fontweight: str = "normal", - ha: str = "center", - va: str = "center", - rounded: bool = True, -) -> None: - """Draw a labeled box on the axes.""" - if rounded: - rect = FancyBboxPatch( - (x, y), - w, - h, - boxstyle="round,pad=0.05", - lw=lw, - edgecolor=LN, - facecolor=fill, - ) - else: - rect = mpatches.Rectangle( - (x, y), w, h, lw=lw, edgecolor=LN, facecolor=fill - ) - ax.add_patch(rect) - ax.text( - x + w / 2, - y + h / 2, - text, - ha=ha, - va=va, - fontsize=fontsize, - fontweight=fontweight, - wrap=True, - ) - - -def draw_arrow( - ax: Axes, - x1: float, - y1: float, - x2: float, - y2: float, - *, - lw: float = 1.2, - style: str = "->", - color: str = LN, -) -> None: - """Draw an arrow between two points.""" - ax.annotate( - "", - xy=(x2, y2), - xytext=(x1, y1), - arrowprops={ - "arrowstyle": style, - "color": color, - "lw": lw, - }, - ) - - -# ============================================================ -# 1. PAYOFF MATRIX + ALL CRITERIA BAR CHART -# ============================================================ -def _draw_payoff_table(ax: Axes) -> None: - """Draw the payoff matrix table on the left panel.""" - ax.axis("off") - ax.set_xlim(0, 6) - ax.set_ylim(0, 6) - ax.set_title( - "Macierz wypłat (tys. zł)", - fontsize=FS_TITLE, - fontweight="bold", - pad=8, - ) - - headers_col = ["", "S₁\n(dobra)", "S₂\n(średnia)", "S₃\n(zła)"] - rows = [ - ["A₁ (fabryka)", "200", "50", "-100"], - ["A₂ (sklep)", "80", "70", "40"], - ["A₃ (obligacje)", "30", "30", "30"], - ] - - col_w = [1.8, 1.2, 1.2, 1.2] - row_h = 0.7 - start_y = 4.5 - start_x = 0.2 - - # Draw header row - x = start_x - for j, h in enumerate(headers_col): - fill = GRAY2 if j > 0 else GRAY3 - rect = mpatches.Rectangle( - (x, start_y), - col_w[j], - row_h, - lw=1, - edgecolor=LN, - facecolor=fill, - ) - ax.add_patch(rect) - ax.text( - x + col_w[j] / 2, - start_y + row_h / 2, - h, - ha="center", - va="center", - fontsize=FS, - fontweight="bold", - ) - x += col_w[j] - - # Draw data rows - for i, row in enumerate(rows): - x = start_x - y = start_y - (i + 1) * row_h - for j, val in enumerate(row): - fill = GRAY4 if j == 0 else ( - "white" if i % 2 == 0 else GRAY1 - ) - if val.startswith("-"): - fill = "#D8D8D8" - rect = mpatches.Rectangle( - (x, y), - col_w[j], - row_h, - lw=1, - edgecolor=LN, - facecolor=fill, - ) - ax.add_patch(rect) - fw = "bold" if j == 0 else "normal" - ax.text( - x + col_w[j] / 2, - y + row_h / 2, - val, - ha="center", - va="center", - fontsize=FS, - fontweight=fw, - ) - x += col_w[j] - - # Probability row for EV - x = start_x - y = start_y - 4 * row_h - probs = ["p (dla E[X]):", "0.5", "0.3", "0.2"] - for j, val in enumerate(probs): - fill = GRAY5 if j > 0 else GRAY3 - rect = mpatches.Rectangle( - (x, y), - col_w[j], - row_h * 0.7, - lw=1, - edgecolor=LN, - facecolor=fill, - ) - ax.add_patch(rect) - ax.text( - x + col_w[j] / 2, - y + row_h * 0.35, - val, - ha="center", - va="center", - fontsize=7, - fontweight="bold", - style="italic", - ) - x += col_w[j] - - -def _draw_criteria_bars(ax2: Axes) -> None: - """Draw the criteria comparison bar chart on the right panel.""" - criteria = [ - "E[X]", - "Laplace", - "Maximax", - "Maximin", - "Hurwicz\n\u03b1=0.6", - "Savage", - ] - - ev = [95, 69, 30] - laplace = [50, 63.3, 30] - maximax = [200, 80, 30] - maximin = [-100, 40, 30] - hurwicz = [80, 64, 30] - savage_maxregret = [140, 120, 170] - - winners = [0, 1, 0, 1, 0, 1] - - x_pos = np.arange(len(criteria)) - width = 0.22 - hatches = ["///", "...", "xxx"] - labels = ["A₁ (fabryka)", "A₂ (sklep)", "A₃ (obligacje)"] - - all_vals = [ - [ - ev[0], laplace[0], maximax[0], - maximin[0], hurwicz[0], savage_maxregret[0], - ], - [ - ev[1], laplace[1], maximax[1], - maximin[1], hurwicz[1], savage_maxregret[1], - ], - [ - ev[2], laplace[2], maximax[2], - maximin[2], hurwicz[2], savage_maxregret[2], - ], - ] - - for i in range(_DATA_STATE_COLS): - ax2.bar( - x_pos + (i - 1) * width, - all_vals[i], - width, - label=labels[i], - color="white", - edgecolor=LN, - hatch=hatches[i], - lw=0.8, - ) - - for c_idx in range(len(criteria)): - w = winners[c_idx] - val = all_vals[w][c_idx] - ax2.text( - x_pos[c_idx] + (w - 1) * width, - val + 5, - "★", - ha="center", - va="bottom", - fontsize=10, - fontweight="bold", - ) - - ax2.set_xticks(x_pos) - ax2.set_xticklabels(criteria, fontsize=7) - ax2.set_ylabel("Wartość kryterium", fontsize=8) - ax2.set_title( - "Porównanie kryteriów", - fontsize=FS_TITLE, - fontweight="bold", - pad=8, - ) - ax2.legend(fontsize=7, loc="upper right") - ax2.axhline(y=0, color=LN, lw=0.5, ls="-") - ax2.spines["top"].set_visible(False) - ax2.spines["right"].set_visible(False) - ax2.tick_params(labelsize=7) - - ax2.text( - 5, - -30, - "(Savage: niżej\n= lepiej)", - fontsize=6, - ha="center", - va="top", - style="italic", - ) - - -def draw_criteria_comparison() -> None: - """Draw payoff matrix and criteria comparison chart.""" - fig, axes = plt.subplots( - 1, - 2, - figsize=(8.27, 4.5), - gridspec_kw={"width_ratios": [1.2, 1]}, - ) - - _draw_payoff_table(axes[0]) - _draw_criteria_bars(axes[1]) - - plt.tight_layout() - outpath = str(Path(OUTPUT_DIR) / "q31_criteria_comparison.png") - fig.savefig(outpath, dpi=DPI, bbox_inches="tight", facecolor=BG) - plt.close(fig) - _logger.info(" Saved: %s", outpath) - - -# ============================================================ -# 2. REGRET MATRIX CONSTRUCTION -# ============================================================ -def _draw_original_payoff( - ax: Axes, - start_y: float, - row_h: float, -) -> None: - """Draw the original payoff matrix (left side of regret fig).""" - ax.text( - 2.2, - 6.3, - "Krok 1: Macierz wypłat", - fontsize=9, - fontweight="bold", - ha="center", - va="center", - ) - - col_w = 1.0 - headers = ["", "S₁", "S₂", "S₃"] - data = [ - ["A₁", "200", "50", "-100"], - ["A₂", "80", "70", "40"], - ["A₃", "30", "30", "30"], - ] - start_x = 0.3 - - for j, h in enumerate(headers): - w = 0.7 if j == 0 else col_w - x = start_x + (0 if j == 0 else 0.7 + (j - 1) * col_w) - rect = mpatches.Rectangle( - (x, start_y), - w, - row_h, - lw=1, - edgecolor=LN, - facecolor=GRAY2, - ) - ax.add_patch(rect) - ax.text( - x + w / 2, - start_y + row_h / 2, - h, - ha="center", - va="center", - fontsize=FS, - fontweight="bold", - ) - - for i, row in enumerate(data): - y = start_y - (i + 1) * row_h - for j, val in enumerate(row): - w = 0.7 if j == 0 else col_w - x = start_x + (0 if j == 0 else 0.7 + (j - 1) * col_w) - fill = GRAY4 if j == 0 else "white" - rect = mpatches.Rectangle( - (x, y), - w, - row_h, - lw=1, - edgecolor=LN, - facecolor=fill, - ) - ax.add_patch(rect) - ax.text( - x + w / 2, - y + row_h / 2, - val, - ha="center", - va="center", - fontsize=FS, - ) - - # Max per column annotation - max_y = start_y - _DATA_STATE_COLS * row_h - 0.1 - col_maxes = ["max=200", "max=70", "max=40"] - for idx, label in enumerate(col_maxes): - ax.text( - start_x + 0.7 + (idx + 0.5) * col_w, - max_y, - label, - fontsize=7, - ha="center", - va="top", - fontweight="bold", - color="#333", - ) - - # Arrow - ax.annotate( - "", - xy=(5.0, 4.8), - xytext=(4.2, 4.8), - arrowprops={"arrowstyle": "->", "color": LN, "lw": 2}, - ) - ax.text( - 4.6, - 5.0, - "rᵢⱼ = max - aᵢⱼ", - fontsize=8, - ha="center", - va="bottom", - fontweight="bold", - ) - - -def _draw_regret_table( - ax: Axes, - start_y: float, - row_h: float, -) -> None: - """Draw the regret matrix (right side of regret fig).""" - ax.text( - 7.5, - 6.3, - "Krok 2: Macierz żalu", - fontsize=9, - fontweight="bold", - ha="center", - va="center", - ) - - regret_data = [ - ["A₁", "0", "20", "140"], - ["A₂", "120", "0", "0"], - ["A₃", "170", "40", "10"], - ] - headers2 = ["", "S₁", "S₂", "S₃", "max rᵢ"] - start_x2 = 5.3 - - for j, h in enumerate(headers2): - w = 0.7 if j == 0 else ( - 0.9 if j < _REGRET_HEADER_COLS else 1.0 - ) - x = start_x2 - if j == 0: - x = start_x2 - elif j <= _DATA_STATE_COLS: - x = start_x2 + 0.7 + (j - 1) * 0.9 - else: - x = start_x2 + 0.7 + _DATA_STATE_COLS * 0.9 - rect = mpatches.Rectangle( - (x, start_y), - w, - row_h, - lw=1, - edgecolor=LN, - facecolor=( - GRAY2 if j < _REGRET_HEADER_COLS else GRAY3 - ), - ) - ax.add_patch(rect) - ax.text( - x + w / 2, - start_y + row_h / 2, - h, - ha="center", - va="center", - fontsize=FS, - fontweight="bold", - ) - - max_regrets = [140, 120, 170] - for i, row in enumerate(regret_data): - y = start_y - (i + 1) * row_h - for j, val in enumerate(row): - w = 0.7 if j == 0 else 0.9 - x = start_x2 + ( - 0 if j == 0 else 0.7 + (j - 1) * 0.9 - ) - fill = GRAY4 if j == 0 else "white" - if j > 0 and int(val) == max_regrets[i]: - fill = GRAY2 - rect = mpatches.Rectangle( - (x, y), - w, - row_h, - lw=1, - edgecolor=LN, - facecolor=fill, - ) - ax.add_patch(rect) - fw = ( - "bold" - if (j > 0 and int(val) == max_regrets[i]) - else "normal" - ) - ax.text( - x + w / 2, - y + row_h / 2, - val, - ha="center", - va="center", - fontsize=FS, - fontweight=fw, - ) - - # Max regret column - x = start_x2 + 0.7 + _DATA_STATE_COLS * 0.9 - w = 1.0 - is_winner = max_regrets[i] == min(max_regrets) - fill = "#C8C8C8" if is_winner else GRAY1 - rect = mpatches.Rectangle( - (x, y), - w, - row_h, - lw=1.5 if is_winner else 1, - edgecolor=LN, - facecolor=fill, - ) - ax.add_patch(rect) - marker = " ★" if is_winner else "" - ax.text( - x + w / 2, - y + row_h / 2, - f"{max_regrets[i]}{marker}", - ha="center", - va="center", - fontsize=FS, - fontweight="bold", - ) - - -def draw_regret_matrix() -> None: - """Draw the regret matrix construction diagram.""" - fig, ax = plt.subplots(1, 1, figsize=(8.27, 5)) - ax.axis("off") - ax.set_xlim(0, 10) - ax.set_ylim(0, 7) - ax.set_title( - "Kryterium Savage'a \u2014 budowa macierzy żalu", - fontsize=FS_TITLE + 1, - fontweight="bold", - pad=10, - ) - - start_y = 5.5 - row_h = 0.55 - - _draw_original_payoff(ax, start_y, row_h) - _draw_regret_table(ax, start_y, row_h) - - # Bottom conclusion - ax.text( - 5.0, - 2.8, - "Krok 3: Wybierz min z max żalu" - " → A₂ (max żal = 120)", - fontsize=10, - ha="center", - va="center", - fontweight="bold", - bbox={ - "boxstyle": "round,pad=0.3", - "facecolor": GRAY1, - "edgecolor": LN, - "lw": 1.5, - }, - ) - - # Interpretation examples - ax.text( - 5.0, - 2.0, - "Interpretacja żalu: r₁₃ = 140 oznacza:\n" - "\u201eGdyby nastąpił S₃ (zła koniunktura)," - " a wybrałbym A₁,\n" - "żałowałbym, bo najlepszą opcją byłoby" - " A₂ z wynikiem 40 \u2014 traciłbym 140\u201d", - fontsize=7.5, - ha="center", - va="center", - style="italic", - bbox={ - "boxstyle": "round,pad=0.3", - "facecolor": GRAY4, - "edgecolor": GRAY3, - "lw": 0.8, - }, - ) - - # Mnemonic - ax.text( - 5.0, - 0.8, - "Mnemonik: Savage = \u201eŻal jak nóż\u201d\n" - "Maksymalny żal to nóż " - "\u2014 wybierz opcję z NAJMNIEJSZYM nożem", - fontsize=8, - ha="center", - va="center", - fontweight="bold", - bbox={ - "boxstyle": "round,pad=0.3", - "facecolor": "white", - "edgecolor": LN, - "lw": 1, - }, - ) - - plt.tight_layout() - outpath = str(Path(OUTPUT_DIR) / "q31_regret_matrix.png") - fig.savefig(outpath, dpi=DPI, bbox_inches="tight", facecolor=BG) - plt.close(fig) - _logger.info(" Saved: %s", outpath) - - -# ============================================================ -# 3. HURWICZ alpha INTERPOLATION -# ============================================================ -def draw_hurwicz_interpolation() -> None: - """Draw Hurwicz alpha interpolation diagram.""" - fig, ax = plt.subplots(1, 1, figsize=(8.27, 4)) - ax.set_title( - "Kryterium Hurwicza" - " \u2014 wpływ \u03b1 na wybór alternatywy", - fontsize=FS_TITLE + 1, - fontweight="bold", - pad=10, - ) - - alphas = np.linspace(0, 1, 200) - - v1 = alphas * 200 + (1 - alphas) * (-100) - v2 = alphas * 80 + (1 - alphas) * 40 - v3 = alphas * 30 + (1 - alphas) * 30 - - ax.plot( - alphas, v1, "k-", lw=2, - label="A₁ (fabryka): V = 300\u03b1 - 100", - ) - ax.plot( - alphas, v2, "k--", lw=2, - label="A₂ (sklep): V = 40\u03b1 + 40", - ) - ax.plot( - alphas, v3, "k:", lw=2, - label="A₃ (obligacje): V = 30", - ) - - # Crossover A2=A1 - alpha_cross_12 = 140 / 260 - v_cross_12 = 40 * alpha_cross_12 + 40 - - ax.plot(alpha_cross_12, v_cross_12, "ko", markersize=8, zorder=5) - ax.annotate( - f"\u03b1 ≈ {alpha_cross_12:.2f}\nA₁ = A₂", - xy=(alpha_cross_12, v_cross_12), - xytext=(alpha_cross_12 + 0.12, v_cross_12 - 30), - fontsize=8, - fontweight="bold", - arrowprops={ - "arrowstyle": "->", - "color": LN, - "lw": 1, - }, - ) - - # Shade winning regions - ax.axvspan(0, alpha_cross_12, alpha=0.08, color="black") - ax.axvspan(alpha_cross_12, 1, alpha=0.15, color="black") - - ax.text( - alpha_cross_12 / 2, - -60, - "A₂ wygrywa\n(pesymistycznie)", - fontsize=8, - ha="center", - va="center", - bbox={ - "boxstyle": "round", - "facecolor": "white", - "edgecolor": LN, - }, - ) - ax.text( - (alpha_cross_12 + 1) / 2, - 160, - "A₁ wygrywa\n(optymistycznie)", - fontsize=8, - ha="center", - va="center", - bbox={ - "boxstyle": "round", - "facecolor": "white", - "edgecolor": LN, - }, - ) - - # Special alpha values - ax.axvline(x=0, color=LN, lw=0.5, ls=":") - ax.axvline(x=1, color=LN, lw=0.5, ls=":") - ax.text( - 0, - -115, - "\u03b1=0\nmaximin", - fontsize=7, - ha="center", - va="top", - fontweight="bold", - ) - ax.text( - 1, - -115, - "\u03b1=1\nmaximax", - fontsize=7, - ha="center", - va="top", - fontweight="bold", - ) - - ax.set_xlabel("Współczynnik optymizmu \u03b1", fontsize=9) - ax.set_ylabel( - "V(Aᵢ) = \u03b1·max + (1-\u03b1)·min", fontsize=9 - ) - ax.legend(fontsize=8, loc="upper left") - ax.spines["top"].set_visible(False) - ax.spines["right"].set_visible(False) - ax.set_xlim(-0.05, 1.05) - ax.axhline(y=0, color=LN, lw=0.3, ls="-") - ax.tick_params(labelsize=8) - - plt.tight_layout() - outpath = str(Path(OUTPUT_DIR) / "q31_hurwicz_alpha.png") - fig.savefig(outpath, dpi=DPI, bbox_inches="tight", facecolor=BG) - plt.close(fig) - _logger.info(" Saved: %s", outpath) - - -# ============================================================ -# 4. DECISION CRITERIA MNEMONIC MAP -# ============================================================ -def draw_criteria_mnemonic() -> None: - """Draw decision criteria mnemonic map diagram.""" - fig, ax = plt.subplots(1, 1, figsize=(8.27, 6)) - ax.set_xlim(0, 10) - ax.set_ylim(0, 8) - ax.set_aspect("equal") - ax.axis("off") - ax.set_title( - "Mapa mnemoniczna \u2014 6 kryteriów decyzyjnych", - fontsize=FS_TITLE + 2, - fontweight="bold", - pad=10, - ) - - # Central node - draw_box( - ax, - 3.5, - 3.5, - 3, - 1, - "MACIERZ\nWYPŁAT", - fill=GRAY2, - lw=2, - fontsize=11, - fontweight="bold", - ) - - # Criteria boxes around the center - criteria = [ - ( - 0, 6.5, 3, 1.2, - "WARTOŚĆ OCZEKIWANA", - "\u201eMam prawdopodobieństwa\u201d", - "E[Aᵢ] = Σ pⱼ·aᵢⱼ", - ), - ( - 3.5, 6.5, 3, 1.2, - "LAPLACE", - "\u201eWszystko po równo\u201d", - "V = Σaᵢⱼ / n", - ), - ( - 7, 6.5, 3, 1.2, - "MAXIMAX", - "\u201eOptymista: max z max\u201d", - "max maxⱼ aᵢⱼ", - ), - ( - 0, 0.5, 3, 1.2, - "MAXIMIN (Wald)", - "\u201ePesymista: max z min\u201d", - "max minⱼ aᵢⱼ", - ), - ( - 3.5, 0.5, 3, 1.2, - "HURWICZ", - "\u201e\u03b1 pomiędzy\u201d", - "\u03b1·max + (1-\u03b1)·min", - ), - ( - 7, 0.5, 3, 1.2, - "SAVAGE", - "\u201eMin max żalu\u201d", - "min maxⱼ rᵢⱼ", - ), - ] - - fills = [GRAY3, GRAY1, "white", "white", GRAY1, GRAY3] - - for i, (x, y, w, h, title, mnem, formula) in enumerate( - criteria - ): - rect = FancyBboxPatch( - (x, y), - w, - h, - boxstyle="round,pad=0.08", - lw=1.5, - edgecolor=LN, - facecolor=fills[i], - ) - ax.add_patch(rect) - ax.text( - x + w / 2, - y + h * 0.78, - title, - ha="center", - va="center", - fontsize=8, - fontweight="bold", - ) - ax.text( - x + w / 2, - y + h * 0.45, - mnem, - ha="center", - va="center", - fontsize=7, - style="italic", - ) - ax.text( - x + w / 2, - y + h * 0.15, - formula, - ha="center", - va="center", - fontsize=7, - fontweight="bold", - family="monospace", - ) - - # Arrows from center to each box - cx, cy = 5, 4 - bx = x + w / 2 - by_center = y + h / 2 - if by_center > cy: - ax.annotate( - "", - xy=(bx, y), - xytext=(cx, 4.5), - arrowprops={ - "arrowstyle": "->", - "color": LN, - "lw": 1, - "connectionstyle": "arc3,rad=0", - }, - ) - else: - ax.annotate( - "", - xy=(bx, y + h), - xytext=(cx, 3.5), - arrowprops={ - "arrowstyle": "->", - "color": LN, - "lw": 1, - "connectionstyle": "arc3,rad=0", - }, - ) - - # Labels on arrows - arrow_labels = [ - (1.2, 5.6, "znane p"), - (5, 5.6, "p = 1/n"), - (8.7, 5.6, "max ↑"), - (1.2, 2.5, "min ↑"), - (5, 2.5, "podaj \u03b1"), - (8.7, 2.5, "macierz\nżalu"), - ] - for lx, ly, ltext in arrow_labels: - ax.text( - lx, - ly, - ltext, - fontsize=7, - ha="center", - va="center", - bbox={ - "boxstyle": "round,pad=0.15", - "facecolor": "white", - "edgecolor": GRAY3, - "lw": 0.5, - }, - ) - - plt.tight_layout() - outpath = str(Path(OUTPUT_DIR) / "q31_criteria_mnemonic.png") - fig.savefig(outpath, dpi=DPI, bbox_inches="tight", facecolor=BG) - plt.close(fig) - _logger.info(" Saved: %s", outpath) - - -# ============================================================ -# 5. EXPECTED VALUE CRITERION WITH PROBABILITY BARS -# ============================================================ -def draw_expected_value() -> None: - """Draw expected value criterion with probability-weighted bars.""" - fig, axes = plt.subplots(1, 3, figsize=(8.27, 3.5), sharey=True) - fig.suptitle( - "Kryterium wartości oczekiwanej E[X]" - " \u2014 rozkład wyników per alternatywa", - fontsize=FS_TITLE, - fontweight="bold", - y=1.02, - ) - - probs = [0.5, 0.3, 0.2] - alts = [ - ("A₁ (fabryka)", [200, 50, -100], 95), - ("A₂ (sklep)", [80, 70, 40], 69), - ("A₃ (obligacje)", [30, 30, 30], 30), - ] - - hatches = ["///", "...", "xxx"] - - for _idx, (ax, (name, vals, ev)) in enumerate( - zip(axes, alts, strict=False) - ): - x_positions = [0, 0.6, 1.0] - widths = [p * 0.9 for p in probs] - - for i, (v, p, h) in enumerate( - zip(vals, probs, hatches, strict=False) - ): - color = "white" if v >= 0 else GRAY2 - ax.bar( - x_positions[i], - v, - width=widths[i], - color=color, - edgecolor=LN, - hatch=h, - lw=0.8, - align="edge", - ) - offset = 8 if v >= 0 else -12 - ax.text( - x_positions[i] + widths[i] / 2, - v + offset, - f"{v}", - ha="center", - va="center", - fontsize=8, - fontweight="bold", - ) - contrib = v * p - ax.text( - x_positions[i] + widths[i] / 2, - v / 2, - f"{v}x{p}\n={contrib:.0f}", - ha="center", - va="center", - fontsize=6, - style="italic", - ) - - # Expected value line - ax.axhline(y=ev, color=LN, lw=2, ls="--") - ax.text( - 1.35, - ev, - f"E[X]={ev}", - fontsize=8, - fontweight="bold", - va="center", - ha="left", - bbox={ - "boxstyle": "round,pad=0.15", - "facecolor": GRAY1, - "edgecolor": LN, - }, - ) - - ax.set_title(name, fontsize=9, fontweight="bold") - ax.set_xticks([0.225, 0.735, 1.09]) - ax.set_xticklabels(["S₁", "S₂", "S₃"], fontsize=7) - ax.axhline(y=0, color=LN, lw=0.5) - ax.spines["top"].set_visible(False) - ax.spines["right"].set_visible(False) - ax.tick_params(labelsize=7) - - # Star on winner - if ev == _WINNING_EV: - ax.text( - 0.7, - ev + 20, - "★ MAX", - fontsize=9, - fontweight="bold", - ha="center", - va="bottom", - ) - - axes[0].set_ylabel("Wypłata (tys. zł)", fontsize=8) - - plt.tight_layout() - outpath = str(Path(OUTPUT_DIR) / "q31_expected_value.png") - fig.savefig(outpath, dpi=DPI, bbox_inches="tight", facecolor=BG) - plt.close(fig) - _logger.info(" Saved: %s", outpath) - - -# ============================================================ -# 6. DECISION CONDITIONS SPECTRUM -# ============================================================ -def draw_conditions_spectrum() -> None: - """Draw decision conditions spectrum diagram.""" - fig, ax = plt.subplots(1, 1, figsize=(8.27, 3.5)) - ax.set_xlim(0, 10) - ax.set_ylim(0, 5) - ax.set_aspect("equal") - ax.axis("off") - ax.set_title( - "Warunki decyzyjne" - " \u2014 spektrum wiedzy decydenta", - fontsize=FS_TITLE + 1, - fontweight="bold", - pad=10, - ) - - # Three zones - zones = [ - ( - 0.3, 1.5, 2.8, 2.5, - "PEWNOŚĆ", "white", - [ - "Znamy dokładny wynik", - "Przykład: lokata 5%", - "Metoda: po prostu wybierz", - "najlepszy wynik", - ], - ), - ( - 3.5, 1.5, 2.8, 2.5, - "RYZYKO", GRAY1, - [ - "Znamy wyniki I prawdop.", - "Przykład: gra w kości", - "Metoda: wartość", - "oczekiwana E[X]", - ], - ), - ( - 6.7, 1.5, 2.8, 2.5, - "NIEPEWNOŚĆ", GRAY3, - [ - "Znamy wyniki, ale", - "NIE znamy prawdop.", - "Metody: Laplace, maximax,", - "maximin, Hurwicz, Savage", - ], - ), - ] - - for x, y, w, h, title, fill, lines in zones: - rect = FancyBboxPatch( - (x, y), - w, - h, - boxstyle="round,pad=0.1", - lw=2, - edgecolor=LN, - facecolor=fill, - ) - ax.add_patch(rect) - ax.text( - x + w / 2, - y + h - 0.3, - title, - ha="center", - va="center", - fontsize=11, - fontweight="bold", - ) - for i, line in enumerate(lines): - ax.text( - x + w / 2, - y + h - 0.7 - i * 0.4, - line, - ha="center", - va="center", - fontsize=7, - ) - - # Arrows between zones - ax.annotate( - "", - xy=(3.4, 2.75), - xytext=(3.15, 2.75), - arrowprops={"arrowstyle": "->", "color": LN, "lw": 2}, - ) - ax.annotate( - "", - xy=(6.6, 2.75), - xytext=(6.35, 2.75), - arrowprops={"arrowstyle": "->", "color": LN, "lw": 2}, - ) - - # Bottom: knowledge gradient bar - gradient_y = 0.5 - gradient_h = 0.5 - n_steps = 50 - for i in range(n_steps): - x = 0.3 + i * (9.2 / n_steps) - w = 9.2 / n_steps + 0.01 - gray_val = 1 - (i / n_steps) * 0.7 - rect = mpatches.Rectangle( - (x, gradient_y), - w, - gradient_h, - lw=0, - facecolor=str(gray_val), - ) - ax.add_patch(rect) - - rect = mpatches.Rectangle( - (0.3, gradient_y), - 9.2, - gradient_h, - lw=1.5, - edgecolor=LN, - facecolor="none", - ) - ax.add_patch(rect) - - ax.text( - 0.3, gradient_y - 0.15, - "Dużo wiedzy", fontsize=7, ha="left", va="top", - ) - ax.text( - 9.5, gradient_y - 0.15, - "Mało wiedzy", fontsize=7, ha="right", va="top", - ) - ax.text( - 4.95, - gradient_y + gradient_h / 2, - "POZIOM WIEDZY DECYDENTA", - fontsize=8, - fontweight="bold", - ha="center", - va="center", - color="white", - ) - - plt.tight_layout() - outpath = str(Path(OUTPUT_DIR) / "q31_conditions_spectrum.png") - fig.savefig(outpath, dpi=DPI, bbox_inches="tight", facecolor=BG) - plt.close(fig) - _logger.info(" Saved: %s", outpath) - - -# ============================================================ -# MAIN -# ============================================================ if __name__ == "__main__": logging.basicConfig(level=logging.INFO) _logger.info("Generating PYTANIE 31 diagrams...") diff --git a/python_pkg/praca_magisterska_video/generate_images/generate_q9_all_diagrams.py b/python_pkg/praca_magisterska_video/generate_images/generate_q9_all_diagrams.py index 453c52a..553e8e2 100755 --- a/python_pkg/praca_magisterska_video/generate_images/generate_q9_all_diagrams.py +++ b/python_pkg/praca_magisterska_video/generate_images/generate_q9_all_diagrams.py @@ -2,1608 +2,60 @@ """Generate ALL diagrams for PYTANIE 9: Procesy i wątki (SOI). Replaces every ASCII diagram with a monochrome A4-printable PNG (300 DPI). +Re-exports all diagram generators from submodules. """ from __future__ import annotations import logging -from typing import TYPE_CHECKING - -import matplotlib as mpl - -mpl.use("Agg") from pathlib import Path +import sys -import matplotlib.patches as mpatches -from matplotlib.patches import FancyBboxPatch -import matplotlib.pyplot as plt -import numpy as np +# Ensure sibling modules are importable when run as a script. +sys.path.insert(0, str(Path(__file__).resolve().parent)) -if TYPE_CHECKING: - from matplotlib.axes import Axes - from matplotlib.figure import Figure +from _q9_basics import ( + gen_memory_layout, + gen_pcb_structure, + gen_process_states, + gen_process_vs_thread, + gen_speed_comparison, + gen_thread_structure, +) +from _q9_classic_sync import ( + gen_classic_problems, + gen_semaphore_concept, + gen_sync_comparison, +) +from _q9_ipc import gen_ipc_details, gen_ipc_table, gen_scenario_table +from _q9_race_deadlock import ( + gen_coffman_strategies, + gen_deadlock_scenario, + gen_race_condition, + gen_starvation_priority, +) + +__all__ = [ + "gen_classic_problems", + "gen_coffman_strategies", + "gen_deadlock_scenario", + "gen_ipc_details", + "gen_ipc_table", + "gen_memory_layout", + "gen_pcb_structure", + "gen_process_states", + "gen_process_vs_thread", + "gen_race_condition", + "gen_scenario_table", + "gen_semaphore_concept", + "gen_speed_comparison", + "gen_starvation_priority", + "gen_sync_comparison", + "gen_thread_structure", +] _logger = logging.getLogger(__name__) -DPI = 300 -BG = "white" -LN = "black" -FS = 8 -FS_TITLE = 11 -FS_SMALL = 6.5 -FS_LABEL = 9 -OUTPUT_DIR = str(Path(__file__).resolve().parent / "img") -Path(OUTPUT_DIR).mkdir(parents=True, exist_ok=True) - -GRAY1 = "#E8E8E8" -GRAY2 = "#D0D0D0" -GRAY3 = "#B8B8B8" -GRAY4 = "#F5F5F5" -GRAY5 = "#C0C0C0" -OCCUPIED_SLOTS = 2 - - -def draw_box( - ax: Axes, - x: float, - y: float, - w: float, - h: float, - text: str, - fill: str = "white", - lw: float = 1.2, - fontsize: float = FS, - fontweight: str = "normal", - ha: str = "center", - va: str = "center", - *, - rounded: bool = True, - edgecolor: str = LN, - linestyle: str = "-", -) -> None: - """Draw box.""" - if rounded: - rect = FancyBboxPatch( - (x, y), - w, - h, - boxstyle="round,pad=0.05", - lw=lw, - edgecolor=edgecolor, - facecolor=fill, - linestyle=linestyle, - ) - else: - rect = mpatches.Rectangle( - (x, y), - w, - h, - lw=lw, - edgecolor=edgecolor, - facecolor=fill, - linestyle=linestyle, - ) - ax.add_patch(rect) - ax.text( - x + w / 2, - y + h / 2, - text, - ha=ha, - va=va, - fontsize=fontsize, - fontweight=fontweight, - wrap=True, - ) - - -def draw_arrow( - ax: Axes, - x1: float, - y1: float, - x2: float, - y2: float, - lw: float = 1.2, - style: str = "->", - color: str = LN, -) -> None: - """Draw arrow.""" - ax.annotate( - "", - xy=(x2, y2), - xytext=(x1, y1), - arrowprops={"arrowstyle": style, "color": color, "lw": lw}, - ) - - -def draw_double_arrow( - ax: Axes, - x1: float, - y1: float, - x2: float, - y2: float, - lw: float = 1.2, - color: str = LN, -) -> None: - """Draw double arrow.""" - ax.annotate( - "", - xy=(x2, y2), - xytext=(x1, y1), - arrowprops={"arrowstyle": "<->", "color": color, "lw": lw}, - ) - - -def save_fig(fig: Figure, name: str) -> None: - """Save fig.""" - path = str(Path(OUTPUT_DIR) / name) - fig.savefig(path, dpi=DPI, bbox_inches="tight", facecolor=BG, pad_inches=0.15) - plt.close(fig) - _logger.info(" Saved: %s", path) - - -def draw_table( - ax: Axes, - headers: list[str], - rows: list[list[str]], - x0: float, - y0: float, - col_widths: list[float], - row_h: float = 0.4, - header_fill: str = GRAY2, - row_fills: list[str] | None = None, - fontsize: float = FS, - header_fontsize: float | None = None, -) -> None: - """Draw a clean table on axes.""" - if header_fontsize is None: - header_fontsize = fontsize - len(headers) - len(rows) - sum(col_widths) - - # Header - cx = x0 - for j, hdr in enumerate(headers): - draw_box( - ax, - cx, - y0, - col_widths[j], - row_h, - hdr, - fill=header_fill, - fontsize=header_fontsize, - fontweight="bold", - rounded=False, - ) - cx += col_widths[j] - - # Rows - for i, row in enumerate(rows): - cy = y0 - (i + 1) * row_h - cx = x0 - fill = GRAY4 if (i % 2 == 0) else "white" - if row_fills and i < len(row_fills): - fill = row_fills[i] - for j, cell in enumerate(row): - fw = "bold" if j == 0 else "normal" - draw_box( - ax, - cx, - cy, - col_widths[j], - row_h, - cell, - fill=fill, - fontsize=fontsize, - fontweight=fw, - rounded=False, - ) - cx += col_widths[j] - - -# ============================================================ -# 1. Process vs Thread comparison table -# ============================================================ -def gen_process_vs_thread() -> None: - """Gen process vs thread.""" - fig, ax = plt.subplots(figsize=(7.5, 4.5)) - ax.set_xlim(0, 10) - ax.set_ylim(-4.5, 1.5) - ax.set_aspect("auto") - ax.axis("off") - ax.set_title( - "Proces vs Wątek — porównanie", fontsize=FS_TITLE, fontweight="bold", pad=10 - ) - - headers = ["Cecha", "Proces", "Wątek"] - col_w = [2.5, 3.5, 3.5] - rows = [ - ["Pamięć", "Własna, izolowana", "Współdzielona (heap)"], - ["Tworzenie", "~1-10 ms", "~10-100 μs (100x szybciej)"], - ["Przełączanie", "~1-5 μs (TLB flush)", "~0.1-0.5 μs (10x)"], - ["Komunikacja", "IPC (pipe, socket, shm)", "Bezpośrednia (wspólna pam.)"], - ["Izolacja", "Pełna — awaria izolowana", "Brak — może zabić proces"], - ["Zastosowanie", "Bezpieczeństwo, izolacja", "Wydajność, współdzielenie"], - ] - draw_table( - ax, - headers, - rows, - x0=0.25, - y0=0.8, - col_widths=col_w, - row_h=0.55, - fontsize=7.5, - header_fontsize=FS_LABEL, - ) - - # Analogy at bottom - ax.text( - 5.0, - -4.2, - "Analogia: Proces = mieszkanie (własny adres) " - "Wątek = pokój w mieszkaniu (wspólna kuchnia = heap)", - ha="center", - fontsize=FS, - style="italic", - bbox={"boxstyle": "round,pad=0.3", "facecolor": GRAY4, "edgecolor": GRAY3}, - ) - - save_fig(fig, "q9_process_vs_thread.png") - - -# ============================================================ -# 2. Memory segments layout -# ============================================================ -def gen_memory_layout() -> None: - """Gen memory layout.""" - fig, ax = plt.subplots(figsize=(6, 5)) - ax.set_xlim(0, 10) - ax.set_ylim(0, 8) - ax.set_aspect("auto") - ax.axis("off") - ax.set_title( - "Segmenty pamięci procesu", fontsize=FS_TITLE, fontweight="bold", pad=10 - ) - - segments = [ - ("STACK ↓", "zmienne lokalne, adresy\npowrotu (każdy wątek WŁASNY)", GRAY1), - ("...", "(wolna przestrzeń)", "white"), - ("HEAP ↑", "malloc/new — dynamiczna\nalokacja (współdzielony)", GRAY4), - ("BSS", "zmienne globalne\nniezainicjalizowane (zerowane)", GRAY2), - ("DATA", "zmienne globalne\nzainicjalizowane", GRAY3), - ("TEXT", "kod maszynowy\n(read-only, współdzielony)", GRAY5), - ] - bx, bw = 2.0, 2.5 - seg_h = 0.9 - gap = 0.05 - top_y = 7.0 - - for i, (name, desc, color) in enumerate(segments): - y = top_y - i * (seg_h + gap) - draw_box( - ax, - bx, - y, - bw, - seg_h, - name, - fill=color, - fontsize=FS_LABEL, - fontweight="bold", - rounded=False, - ) - ax.text(bx + bw + 0.3, y + seg_h / 2, desc, fontsize=7.5, va="center") - - # Address labels - ax.text( - bx - 0.2, - top_y + seg_h / 2, - "wysoki\nadres", - fontsize=FS_SMALL, - va="center", - ha="right", - style="italic", - ) - bottom_y = top_y - 5 * (seg_h + gap) - ax.text( - bx - 0.2, - bottom_y + seg_h / 2, - "niski\nadres", - fontsize=FS_SMALL, - va="center", - ha="right", - style="italic", - ) - - # Arrows for growth - ax.annotate( - "", - xy=(bx - 0.5, top_y - 0.1), - xytext=(bx - 0.5, top_y + seg_h + 0.1), - arrowprops={"arrowstyle": "->", "lw": 1.5, "color": LN}, - ) - ax.text(bx - 0.9, top_y + 0.4, "rośnie\nw dół", fontsize=FS_SMALL, ha="center") - - heap_y = top_y - 2 * (seg_h + gap) - ax.annotate( - "", - xy=(bx - 0.5, heap_y + seg_h + 0.1), - xytext=(bx - 0.5, heap_y - 0.1), - arrowprops={"arrowstyle": "->", "lw": 1.5, "color": LN}, - ) - ax.text(bx - 0.9, heap_y + 0.5, "rośnie\nw górę", fontsize=FS_SMALL, ha="center") - - save_fig(fig, "q9_memory_layout.png") - - -# ============================================================ -# 3. Process states diagram -# ============================================================ -def gen_process_states() -> None: - """Gen process states.""" - fig, ax = plt.subplots(figsize=(7, 3.5)) - ax.set_xlim(0, 12) - ax.set_ylim(0, 5) - ax.set_aspect("auto") - ax.axis("off") - ax.set_title( - "Stany procesu — diagram przejść", fontsize=FS_TITLE, fontweight="bold", pad=10 - ) - - states = { - "NEW": (1.0, 2.5), - "READY": (3.5, 2.5), - "RUNNING": (6.5, 2.5), - "BLOCKED": (6.5, 0.5), - "TERMINATED": (10.0, 2.5), - } - fills = { - "NEW": GRAY4, - "READY": GRAY1, - "RUNNING": GRAY3, - "BLOCKED": GRAY2, - "TERMINATED": GRAY5, - } - bw, bh = 1.8, 0.9 - for name, (x, y) in states.items(): - draw_box( - ax, - x, - y, - bw, - bh, - name, - fill=fills[name], - fontsize=FS_LABEL, - fontweight="bold", - ) - - # Transitions - transitions = [ - ("NEW", "READY", "admit"), - ("READY", "RUNNING", "dispatch\n(scheduler)"), - ("RUNNING", "TERMINATED", "exit"), - ("RUNNING", "BLOCKED", "I/O wait"), - ] - for src, dst, label in transitions: - sx, sy = states[src] - dx, dy = states[dst] - if sy == dy: # horizontal - draw_arrow(ax, sx + bw, sy + bh / 2, dx, dy + bh / 2, lw=1.5) - mx = (sx + bw + dx) / 2 - ax.text( - mx, - sy + bh / 2 + 0.25, - label, - fontsize=FS_SMALL, - ha="center", - va="bottom", - ) - else: # vertical - draw_arrow(ax, sx + bw / 2, sy, dx + bw / 2, dy + bh, lw=1.5) - ax.text( - sx + bw + 0.2, - (sy + dy + bh) / 2, - label, - fontsize=FS_SMALL, - ha="left", - va="center", - ) - - # BLOCKED → READY - bx, by = states["BLOCKED"] - rx, ry = states["READY"] - ax.annotate( - "", - xy=(rx + bw / 2, ry), - xytext=(bx - 0.3, by + bh / 2), - arrowprops={ - "arrowstyle": "->", - "lw": 1.5, - "color": LN, - "connectionstyle": "arc3,rad=0.3", - }, - ) - ax.text(3.5, 0.7, "I/O done", fontsize=FS_SMALL, ha="center") - - # RUNNING → READY (preemption) - rux, ruy = states["RUNNING"] - draw_arrow(ax, rux, ruy + bh, rx + bw, ry + bh, lw=1.2) - ax.text(5.0, 3.7, "preempt /\ntimeout", fontsize=FS_SMALL, ha="center") - - save_fig(fig, "q9_process_states.png") - - -# ============================================================ -# 4. Thread structure within process -# ============================================================ -def gen_thread_structure() -> None: - """Gen thread structure.""" - fig, ax = plt.subplots(figsize=(8, 4.5)) - ax.set_xlim(0, 10) - ax.set_ylim(0, 6) - ax.set_aspect("auto") - ax.axis("off") - ax.set_title( - "Wątki wewnątrz procesu (PID=42)", fontsize=FS_TITLE, fontweight="bold", pad=10 - ) - - # Shared memory region - draw_box(ax, 0.5, 3.5, 9.0, 1.8, "", fill=GRAY1, rounded=False, lw=2) - ax.text(5.0, 5.0, "WSPÓŁDZIELONE", fontsize=FS, fontweight="bold", ha="center") - - labels_shared = ["TEXT", "DATA", "BSS", "HEAP", "pliki", "PID"] - for i, lab in enumerate(labels_shared): - x = 1.0 + i * 1.4 - draw_box( - ax, - x, - 3.8, - 1.1, - 0.6, - lab, - fill=GRAY3, - fontsize=FS, - fontweight="bold", - rounded=False, - ) - ax.text(x + 0.55, 4.6, lab, fontsize=FS_SMALL, ha="center", color="#555555") - - # Per-thread regions - draw_box( - ax, 0.5, 0.5, 9.0, 2.7, "", fill="white", rounded=False, lw=2, linestyle="--" - ) - ax.text( - 5.0, 2.95, "PRYWATNE (każdy wątek)", fontsize=FS, fontweight="bold", ha="center" - ) - - for i in range(3): - x = 1.0 + i * 3.0 - tid = i + 1 - draw_box(ax, x, 0.7, 2.3, 2.0, "", fill=GRAY4, rounded=False) - ax.text( - x + 1.15, - 2.4, - f"Wątek {tid}", - fontsize=FS_LABEL, - fontweight="bold", - ha="center", - ) - items = [f"stos_{tid}", f"rejestry_{tid}", f"PC_{tid}", f"TID={40 + tid}"] - for j, item in enumerate(items): - ax.text( - x + 1.15, - 2.0 - j * 0.35, - item, - fontsize=FS_SMALL, - ha="center", - family="monospace", - ) - - save_fig(fig, "q9_thread_structure.png") - - -# ============================================================ -# 5. PCB structure -# ============================================================ -def gen_pcb_structure() -> None: - """Gen pcb structure.""" - fig, ax = plt.subplots(figsize=(5, 3.5)) - ax.set_xlim(0, 8) - ax.set_ylim(0, 5.5) - ax.set_aspect("auto") - ax.axis("off") - ax.set_title( - "PCB (Process Control Block)", fontsize=FS_TITLE, fontweight="bold", pad=10 - ) - - fields = [ - ("PID", "42"), - ("Stan", "READY / RUNNING / BLOCKED"), - ("Rejestry CPU", "EAX, EBX, ESP, EIP ..."), - ("Tablice stron", "mapowanie wirtualne → fizyczne"), - ("Otwarte pliki", "fd[0], fd[1], fd[2] ..."), - ("Priorytety", "nice value, scheduling class"), - ("Statystyki", "CPU time, I/O count"), - ] - - top_y = 4.8 - for i, (field, value) in enumerate(fields): - y = top_y - i * 0.55 - draw_box( - ax, - 0.5, - y, - 2.2, - 0.45, - field, - fill=GRAY2, - fontsize=FS, - fontweight="bold", - rounded=False, - ) - draw_box(ax, 2.7, y, 4.5, 0.45, value, fill=GRAY4, fontsize=FS, rounded=False) - - ax.text( - 4.0, - 0.3, - "Context switch = zapisz PCB starego → wczytaj PCB nowego", - fontsize=FS_SMALL, - ha="center", - style="italic", - ) - - save_fig(fig, "q9_pcb_structure.png") - - -# ============================================================ -# 6. Speed comparison -# ============================================================ -def gen_speed_comparison() -> None: - """Gen speed comparison.""" - fig, axes = plt.subplots(1, 2, figsize=(9, 3.5)) - fig.suptitle( - "Szybkość — procesy vs wątki (benchmarki Linux)", - fontsize=FS_TITLE, - fontweight="bold", - ) - - # Creation time panel - ax = axes[0] - ops = ["fork()\n(nowy proces)", "pthread_create()\n(nowy wątek)"] - times = [3.0, 0.05] # ms - colors = [GRAY3, GRAY1] - bars = ax.barh(ops, times, color=colors, edgecolor=LN, height=0.5, linewidth=1.2) - ax.set_xlabel("Czas [ms]", fontsize=FS) - ax.set_title("Tworzenie", fontsize=FS_LABEL, fontweight="bold") - ax.set_xlim(0, 4.5) - for bar, t in zip(bars, times, strict=False): - ax.text( - bar.get_width() + 0.1, - bar.get_y() + bar.get_height() / 2, - f"{t} ms", - va="center", - fontsize=FS, - ) - ax.text( - 2.5, - -0.6, - "~100x szybciej", - fontsize=FS, - ha="center", - fontweight="bold", - transform=ax.get_xaxis_transform(), - ) - ax.tick_params(labelsize=FS) - - # Right: context switch - ax = axes[1] - ops2 = ["Proces→Proces\n(TLB flush)", "Wątek→Wątek\n(TLB warm)"] - times2 = [3000, 300] # ns - bars2 = ax.barh(ops2, times2, color=colors, edgecolor=LN, height=0.5, linewidth=1.2) - ax.set_xlabel("Czas [ns]", fontsize=FS) - ax.set_title("Przełączanie kontekstu", fontsize=FS_LABEL, fontweight="bold") - ax.set_xlim(0, 4500) - for bar, t in zip(bars2, times2, strict=False): - ax.text( - bar.get_width() + 50, - bar.get_y() + bar.get_height() / 2, - f"{t} ns", - va="center", - fontsize=FS, - ) - ax.text( - 2500, - -0.6, - "~10x szybciej", - fontsize=FS, - ha="center", - fontweight="bold", - transform=ax.get_xaxis_transform(), - ) - ax.tick_params(labelsize=FS) - - fig.tight_layout(rect=[0, 0.05, 1, 0.92]) - save_fig(fig, "q9_speed_comparison.png") - - -# ============================================================ -# 7. Scenario table (when to use process vs thread) -# ============================================================ -def gen_scenario_table() -> None: - """Gen scenario table.""" - fig, ax = plt.subplots(figsize=(8.5, 4.5)) - ax.set_xlim(0, 11) - ax.set_ylim(-5.5, 1) - ax.set_aspect("auto") - ax.axis("off") - ax.set_title( - "Kiedy proces, kiedy wątek? — typowe scenariusze", - fontsize=FS_TITLE, - fontweight="bold", - pad=10, - ) - - headers = ["Scenariusz", "Wybór", "Dlaczego?"] - col_w = [3.5, 2.5, 4.5] - rows = [ - ["Serwer WWW (Apache)", "Proces", "izolacja klientów"], - ["Serwer WWW (nginx)", "Wątek / async", "szybkość, cooperacja"], - ["Przeglądarka (karty)", "Proces", "crash isolation"], - ["Przeglądarka (JS+render)", "Wątek", "współdzielony DOM"], - ["Gra (fizyka+rendering)", "Wątek", "współdzielony świat gry"], - ["Kompilacja (make -j8)", "Proces", "izolacja, prostota"], - ["Baza danych (zapytania)", "Wątek", "współdzielony cache"], - ["Microservices", "Proces (kontener)", "izolacja, deployment"], - ] - draw_table( - ax, headers, rows, x0=0.25, y0=0.5, col_widths=col_w, row_h=0.5, fontsize=7 - ) - - save_fig(fig, "q9_scenario_table.png") - - -# ============================================================ -# 8. IPC details: pipe, shared memory, socket (3-panel) -# ============================================================ -def gen_ipc_details() -> None: - """Gen ipc details.""" - fig, axes = plt.subplots(1, 3, figsize=(11, 3.5)) - fig.suptitle("Mechanizmy IPC — szczegóły", fontsize=FS_TITLE, fontweight="bold") - - # Panel 1: Pipe - ax = axes[0] - ax.set_xlim(0, 8) - ax.set_ylim(0, 5) - ax.set_aspect("auto") - ax.axis("off") - ax.set_title("Pipe (potok)", fontsize=FS_LABEL, fontweight="bold") - - draw_box(ax, 0.2, 2.0, 1.8, 1.2, "Proces A\n(ls)\nstdout", fill=GRAY1, fontsize=FS) - draw_box( - ax, - 3.0, - 2.0, - 1.8, - 1.2, - "Bufor\njądra\n(4 KB)", - fill=GRAY2, - fontsize=FS, - fontweight="bold", - ) - draw_box(ax, 5.8, 2.0, 1.8, 1.2, "Proces B\n(grep)\nstdin", fill=GRAY1, fontsize=FS) - draw_arrow(ax, 2.0, 2.6, 3.0, 2.6, lw=1.5) - ax.text(2.5, 3.0, "write()\nfd[1]", fontsize=FS_SMALL, ha="center") - draw_arrow(ax, 4.8, 2.6, 5.8, 2.6, lw=1.5) - ax.text(5.3, 3.0, "read()\nfd[0]", fontsize=FS_SMALL, ha="center") - ax.text( - 4.0, - 0.8, - "Jednokierunkowy\nBufor pełny → write() blokuje", - fontsize=FS_SMALL, - ha="center", - style="italic", - bbox={"boxstyle": "round,pad=0.2", "facecolor": GRAY4, "edgecolor": GRAY3}, - ) - - # Panel 2: Shared Memory - ax = axes[1] - ax.set_xlim(0, 8) - ax.set_ylim(0, 5) - ax.set_aspect("auto") - ax.axis("off") - ax.set_title("Shared Memory", fontsize=FS_LABEL, fontweight="bold") - - draw_box(ax, 0.3, 3.0, 2.2, 1.2, "Proces A\nstrona 7", fill=GRAY1, fontsize=FS) - draw_box(ax, 5.5, 3.0, 2.2, 1.2, "Proces B\nstrona 3", fill=GRAY1, fontsize=FS) - draw_box( - ax, - 2.8, - 1.0, - 2.4, - 1.2, - "RAM\nramka 42", - fill=GRAY3, - fontsize=FS, - fontweight="bold", - ) - draw_arrow(ax, 2.0, 3.0, 3.5, 2.2, lw=1.5) - draw_arrow(ax, 6.0, 3.0, 4.5, 2.2, lw=1.5) - ax.text( - 4.0, - 0.3, - "Zero kopiowania!\nA pisze → B widzi od razu\nWymaga synchronizacji (semafor)", - fontsize=FS_SMALL, - ha="center", - style="italic", - bbox={"boxstyle": "round,pad=0.2", "facecolor": GRAY4, "edgecolor": GRAY3}, - ) - - # Panel 3: Socket - ax = axes[2] - ax.set_xlim(0, 8) - ax.set_ylim(0, 5) - ax.set_aspect("auto") - ax.axis("off") - ax.set_title("Socket", fontsize=FS_LABEL, fontweight="bold") - - # Network socket - draw_box( - ax, 0.3, 3.2, 1.8, 0.9, "Klient", fill=GRAY1, fontsize=FS, fontweight="bold" - ) - draw_box( - ax, 5.5, 3.2, 1.8, 0.9, "Serwer", fill=GRAY1, fontsize=FS, fontweight="bold" - ) - draw_double_arrow(ax, 2.1, 3.65, 5.5, 3.65, lw=1.5) - ax.text(3.8, 4.3, "TCP/IP (sieciowy)", fontsize=FS, ha="center", fontweight="bold") - - # Unix socket - draw_box( - ax, 0.3, 1.3, 1.8, 0.9, "Proces A", fill=GRAY4, fontsize=FS, fontweight="bold" - ) - draw_box( - ax, 5.5, 1.3, 1.8, 0.9, "Proces B", fill=GRAY4, fontsize=FS, fontweight="bold" - ) - draw_double_arrow(ax, 2.1, 1.75, 5.5, 1.75, lw=1.5) - ax.text( - 3.8, - 2.4, - "Unix domain socket\n(/tmp/app.sock)", - fontsize=FS, - ha="center", - fontweight="bold", - ) - - ax.text( - 3.8, - 0.5, - "Dwukierunkowy\nNajbardziej uniwersalny IPC", - fontsize=FS_SMALL, - ha="center", - style="italic", - bbox={"boxstyle": "round,pad=0.2", "facecolor": GRAY4, "edgecolor": GRAY3}, - ) - - fig.tight_layout(rect=[0, 0, 1, 0.9]) - save_fig(fig, "q9_ipc_details.png") - - -# ============================================================ -# 9. IPC comparison table -# ============================================================ -def gen_ipc_table() -> None: - """Gen ipc table.""" - fig, ax = plt.subplots(figsize=(8.5, 3.5)) - ax.set_xlim(0, 11) - ax.set_ylim(-4.5, 1) - ax.set_aspect("auto") - ax.axis("off") - ax.set_title( - "Porównanie mechanizmów IPC", fontsize=FS_TITLE, fontweight="bold", pad=10 - ) - - headers = ["Mechanizm", "Kierunek", "Szybkość", "Zastosowanie"] - col_w = [2.5, 2.0, 2.5, 3.5] - rows = [ - ["Pipe", "jednokierunkowy", "średnia", "ls | grep"], - ["Named Pipe", "jednokierunkowy", "średnia", "demon → klient"], - ["Shared Memory", "dwukierunkowy", "NAJSZYBSZA", "video, bazy danych"], - ["Message Queue", "dwukierunkowy", "średnia", "wieloproducentowe"], - ["Socket", "dwukierunkowy", "wolna (sieć)", "klient-serwer"], - ["Signal", "jednokierunkowy", "natychmiastowa", "powiadomienia (nr)"], - ] - draw_table( - ax, headers, rows, x0=0.25, y0=0.5, col_widths=col_w, row_h=0.5, fontsize=7.5 - ) - - save_fig(fig, "q9_ipc_table.png") - - -# ============================================================ -# 10. Race condition (simple x + bank timeline) -# ============================================================ -def gen_race_condition() -> None: - """Gen race condition.""" - fig, axes = plt.subplots(1, 2, figsize=(11, 5)) - fig.suptitle( - "Wyścig (Race Condition) — przykłady", fontsize=FS_TITLE, fontweight="bold" - ) - - # Panel 1: simple x increment - ax = axes[0] - ax.set_xlim(0, 8) - ax.set_ylim(0, 7) - ax.set_aspect("auto") - ax.axis("off") - ax.set_title("Prosty wyścig: x = x + 1", fontsize=FS_LABEL, fontweight="bold") - - # Timeline - steps_a = ["czytaj x (=0)", "dodaj 1", "zapisz x (=1)"] - steps_b = ["czytaj x (=0)", "dodaj 1", "zapisz x (=1)"] - ax.text(2.0, 6.3, "Wątek A", fontsize=FS_LABEL, ha="center", fontweight="bold") - ax.text(6.0, 6.3, "Wątek B", fontsize=FS_LABEL, ha="center", fontweight="bold") - ax.plot([2, 2], [0.8, 6.0], color=LN, lw=1) - ax.plot([6, 6], [0.8, 6.0], color=LN, lw=1) - - for i, (sa, sb) in enumerate(zip(steps_a, steps_b, strict=False)): - y = 5.3 - i * 1.2 - draw_box(ax, 0.5, y, 3.0, 0.6, sa, fill=GRAY4, fontsize=FS) - draw_box(ax, 4.5, y - 0.3, 3.0, 0.6, sb, fill=GRAY1, fontsize=FS) - - ax.text( - 4.0, - 0.4, - "Wynik: x = 1 (powinno 2!)", - fontsize=FS, - ha="center", - fontweight="bold", - color="#C62828", - bbox={"boxstyle": "round", "facecolor": "#F8D7DA", "edgecolor": "#C62828"}, - ) - - # Panel 2: bank account - ax = axes[1] - ax.set_xlim(0, 10) - ax.set_ylim(0, 7) - ax.set_aspect("auto") - ax.axis("off") - ax.set_title("Konto bankowe: saldo = 1000 zł", fontsize=FS_LABEL, fontweight="bold") - - ax.text(2.5, 6.3, "Wątek A (+500)", fontsize=FS, ha="center", fontweight="bold") - ax.text(7.5, 6.3, "Wątek B (-200)", fontsize=FS, ha="center", fontweight="bold") - ax.plot([2.5, 2.5], [0.8, 6.0], color=LN, lw=1) - ax.plot([7.5, 7.5], [0.8, 6.0], color=LN, lw=1) - - events = [ - ("t1", "czytaj → 1000", "", 5.3), - ("t2", "", "czytaj → 1000", 4.6), - ("t3", "1000+500=1500", "", 3.9), - ("t4", "", "1000-200=800", 3.2), - ("t5", "zapisz 1500", "", 2.5), - ("t6", "", "zapisz 800 ✗", 1.8), - ] - for t, a, b, y in events: - ax.text(0.3, y + 0.15, t, fontsize=FS_SMALL, fontweight="bold", va="center") - if a: - draw_box(ax, 1.0, y, 3.0, 0.45, a, fill=GRAY4, fontsize=FS_SMALL) - if b: - fill = "#F8D7DA" if "✗" in b else GRAY1 - draw_box(ax, 6.0, y, 3.0, 0.45, b, fill=fill, fontsize=FS_SMALL) - - ax.text( - 5.0, - 0.4, - "Wynik: 800 zł (powinno 1300!)", - fontsize=FS, - ha="center", - fontweight="bold", - color="#C62828", - bbox={"boxstyle": "round", "facecolor": "#F8D7DA", "edgecolor": "#C62828"}, - ) - - fig.tight_layout(rect=[0, 0, 1, 0.9]) - save_fig(fig, "q9_race_condition.png") - - -# ============================================================ -# 11. Deadlock scenario + cycle -# ============================================================ -def gen_deadlock_scenario() -> None: - """Gen deadlock scenario.""" - fig, axes = plt.subplots(1, 2, figsize=(11, 4.5)) - fig.suptitle("Zakleszczenie (Deadlock)", fontsize=FS_TITLE, fontweight="bold") - - # Panel 1: timeline - ax = axes[0] - ax.set_xlim(0, 8) - ax.set_ylim(0, 6) - ax.set_aspect("auto") - ax.axis("off") - ax.set_title("Scenariusz z 2 mutexami", fontsize=FS_LABEL, fontweight="bold") - - ax.text(2.5, 5.3, "Wątek A", fontsize=FS_LABEL, ha="center", fontweight="bold") - ax.text(6.0, 5.3, "Wątek B", fontsize=FS_LABEL, ha="center", fontweight="bold") - - steps = [ - ("lock(mutex1) OK", "", "trzyma", False, 4.5), - ("", "lock(mutex2) OK", "trzyma", False, 3.7), - ("lock(mutex2) ...WAIT", "", "CZEKA!", True, 2.9), - ("", "lock(mutex1) ...WAIT", "CZEKA!", True, 2.1), - ] - for a_text, b_text, _note, is_wait, y in steps: - if a_text: - fill = "#F8D7DA" if is_wait else GRAY4 - draw_box(ax, 0.5, y, 3.3, 0.55, a_text, fill=fill, fontsize=FS_SMALL) - if b_text: - fill = "#F8D7DA" if is_wait else GRAY4 - draw_box(ax, 4.3, y, 3.3, 0.55, b_text, fill=fill, fontsize=FS_SMALL) - - ax.text( - 4.0, - 1.2, - "DEADLOCK!\nŻaden nie odpuści", - fontsize=FS, - ha="center", - fontweight="bold", - color="#C62828", - bbox={"boxstyle": "round", "facecolor": "#F8D7DA", "edgecolor": "#C62828"}, - ) - - # Panel 2: cycle diagram - ax = axes[1] - ax.set_xlim(0, 8) - ax.set_ylim(0, 6) - ax.set_aspect("auto") - ax.axis("off") - ax.set_title("Cykl oczekiwania", fontsize=FS_LABEL, fontweight="bold") - - # Thread boxes - draw_box( - ax, - 0.5, - 3.5, - 2.2, - 1.2, - "Wątek A\ntrzyma Mutex 1", - fill=GRAY1, - fontsize=FS, - fontweight="bold", - ) - draw_box( - ax, - 5.3, - 3.5, - 2.2, - 1.2, - "Wątek B\ntrzyma Mutex 2", - fill=GRAY1, - fontsize=FS, - fontweight="bold", - ) - - # Mutex boxes - draw_box( - ax, 0.5, 1.0, 2.2, 1.0, "Mutex 1", fill=GRAY3, fontsize=FS, fontweight="bold" - ) - draw_box( - ax, 5.3, 1.0, 2.2, 1.0, "Mutex 2", fill=GRAY3, fontsize=FS, fontweight="bold" - ) - - # holds arrows (down) - draw_arrow(ax, 1.6, 3.5, 1.6, 2.0, lw=2) - ax.text(0.9, 2.7, "trzyma", fontsize=FS_SMALL, rotation=90, va="center") - draw_arrow(ax, 6.4, 3.5, 6.4, 2.0, lw=2) - ax.text(7.0, 2.7, "trzyma", fontsize=FS_SMALL, rotation=90, va="center") - - # waits-for arrows (across, red) - draw_arrow(ax, 2.7, 4.3, 5.3, 4.3, lw=2.5, color="#C62828") - ax.text( - 4.0, - 4.7, - "czeka na Mutex 2", - fontsize=FS_SMALL, - ha="center", - fontweight="bold", - color="#C62828", - ) - draw_arrow(ax, 5.3, 3.7, 2.7, 3.7, lw=2.5, color="#C62828") - ax.text( - 4.0, - 3.1, - "czeka na Mutex 1", - fontsize=FS_SMALL, - ha="center", - fontweight="bold", - color="#C62828", - ) - - fig.tight_layout(rect=[0, 0, 1, 0.9]) - save_fig(fig, "q9_deadlock_scenario.png") - - -# ============================================================ -# 12. Coffman conditions + prevention strategies -# ============================================================ -def gen_coffman_strategies() -> None: - """Gen coffman strategies.""" - fig, ax = plt.subplots(figsize=(9, 4)) - ax.set_xlim(0, 11.5) - ax.set_ylim(-3.5, 1) - ax.set_aspect("auto") - ax.axis("off") - ax.set_title( - "Warunki Coffmana — zapobieganie deadlockowi", - fontsize=FS_TITLE, - fontweight="bold", - pad=10, - ) - - headers = ["Warunek", "Opis", "Jak złamać", "Przykład"] - col_w = [2.5, 2.5, 3.0, 3.0] - rows = [ - [ - "1. Mutual Exclusion", - "zasób wyłączny", - "współdzielony zasób", - "Read-write lock", - ], - [ - "2. Hold and Wait", - "trzymaj + czekaj", - "bierz WSZYSTKIE naraz", - "lock(m1,m2) atomowo", - ], - [ - "3. No Preemption", - "nie zabierzesz siłą", - "timeout / trylock", - "pthread_mutex_trylock()", - ], - [ - "4. Circular Wait", - "cykliczne oczekiw.", - "porządek liniowy", - "zawsze m1 przed m2", - ], - ] - draw_table( - ax, headers, rows, x0=0.25, y0=0.5, col_widths=col_w, row_h=0.6, fontsize=7 - ) - - ax.text( - 5.75, - -3.1, - "▸ Najczęstsza strategia: PORZĄDEK LINIOWY — " - "numeruj mutexy, zawsze blokuj rosnąco", - fontsize=FS, - ha="center", - fontweight="bold", - bbox={"boxstyle": "round,pad=0.2", "facecolor": GRAY4, "edgecolor": GRAY3}, - ) - - save_fig(fig, "q9_coffman_strategies.png") - - -# ============================================================ -# 13. Starvation + Priority Inversion (2-panel) -# ============================================================ -def gen_starvation_priority() -> None: - """Gen starvation priority.""" - fig, axes = plt.subplots(1, 2, figsize=(11, 4.5)) - fig.suptitle( - "Zagłodzenie i Inwersja priorytetów", fontsize=FS_TITLE, fontweight="bold" - ) - - # Panel 1: Starvation + aging - ax = axes[0] - ax.set_xlim(0, 8) - ax.set_ylim(0, 6) - ax.set_aspect("auto") - ax.axis("off") - ax.set_title("Zagłodzenie (Starvation)", fontsize=FS_LABEL, fontweight="bold") - - threads = [ - ("Wątek HIGH", "prio=10", GRAY5, 3.0), - ("Wątek HIGH", "prio=9", GRAY3, 2.2), - ("Wątek MED", "prio=5", GRAY2, 1.4), - ("Wątek LOW", "prio=1 → głoduje!", "#F8D7DA", 0.6), - ] - for name, prio, color, y in threads: - draw_box( - ax, 0.5, y, 2.0, 0.6, name, fill=color, fontsize=FS_SMALL, fontweight="bold" - ) - ax.text(2.8, y + 0.3, prio, fontsize=FS_SMALL, va="center") - - ax.text( - 1.5, - 4.2, - "CPU zawsze\ndostaje HIGH!", - fontsize=FS, - ha="center", - fontweight="bold", - ) - draw_arrow(ax, 1.5, 3.9, 1.5, 3.65, lw=1.5) - - # Aging solution - draw_box(ax, 4.5, 1.5, 3.2, 2.5, "", fill=GRAY4, rounded=True) - ax.text(6.1, 3.7, "Rozwiązanie: AGING", fontsize=FS, fontweight="bold", ha="center") - aging = [ - "t=0: prio=1", - "t=100ms: prio=2", - "t=200ms: prio=3", - "...", - "w końcu → CPU!", - ] - for i, line in enumerate(aging): - ax.text( - 6.1, 3.2 - i * 0.4, line, fontsize=FS_SMALL, ha="center", family="monospace" - ) - - # Panel 2: Priority Inversion - ax = axes[1] - ax.set_xlim(0, 10) - ax.set_ylim(0, 6) - ax.set_aspect("auto") - ax.axis("off") - ax.set_title("Inwersja priorytetów", fontsize=FS_LABEL, fontweight="bold") - - # Timeline - labels = ["H (wysoki)", "M (średni)", "L (niski)"] - ys = [4.2, 2.8, 1.4] - for label, y in zip(labels, ys, strict=False): - ax.text(0.3, y + 0.2, label, fontsize=FS, fontweight="bold", va="center") - - # L runs and locks mutex - draw_box(ax, 2.0, ys[2], 1.2, 0.5, "lock(m)", fill=GRAY1, fontsize=FS_SMALL) - - # M preempts L - draw_box(ax, 3.5, ys[1], 3.0, 0.5, "M pracuje...", fill=GRAY3, fontsize=FS_SMALL) - - # H waits for mutex - draw_box( - ax, - 3.5, - ys[0], - 3.0, - 0.5, - "CZEKA na mutex!", - fill="#F8D7DA", - fontsize=FS_SMALL, - fontweight="bold", - ) - - # M finishes, L continues, unlocks - draw_box(ax, 6.8, ys[2], 1.5, 0.5, "unlock(m)", fill=GRAY1, fontsize=FS_SMALL) - draw_box(ax, 8.5, ys[0], 1.2, 0.5, "H runs", fill=GRAY4, fontsize=FS_SMALL) - - # Explanation - ax.text( - 5.0, - 0.5, - "H czeka na M (mimo H > M)!\n" - "Rozwiązanie: Priority Inheritance\n" - "L dziedziczy priorytet H → M nie wypycha L", - fontsize=FS_SMALL, - ha="center", - style="italic", - bbox={"boxstyle": "round,pad=0.3", "facecolor": GRAY4, "edgecolor": GRAY3}, - ) - - ax.text( - 5.0, - 0.0, - "Mars Pathfinder (1997) — klasyczny bug!", - fontsize=FS_SMALL, - ha="center", - fontweight="bold", - ) - - fig.tight_layout(rect=[0, 0, 1, 0.9]) - save_fig(fig, "q9_starvation_priority.png") - - -# ============================================================ -# 14. Bounded buffer + readers-writers + philosophers -# ============================================================ -def _draw_bounded_buffer_panel(ax: Axes) -> None: - """Draw the bounded-buffer (producer-consumer) panel.""" - ax.set_xlim(0, 8) - ax.set_ylim(0, 7) - ax.set_aspect("auto") - ax.axis("off") - ax.set_title( - "Producent-Konsument\n(Bounded Buffer, N=4)", fontsize=FS, fontweight="bold" - ) - - draw_box( - ax, - 0.2, - 4.0, - 2.0, - 1.2, - "Producent\nP(empty)\nP(mutex)\nwstaw()\nV(mutex)\nV(full)", - fill=GRAY1, - fontsize=5.5, - ) - items = ["A", "B", "", ""] - for i, item in enumerate(items): - x = 2.8 + i * 0.9 - fill = GRAY3 if item else "white" - draw_box( - ax, - x, - 4.3, - 0.9, - 0.7, - item, - fill=fill, - fontsize=FS, - fontweight="bold", - rounded=False, - ) - ax.text(4.6, 5.2, "Bufor (N=4)", fontsize=FS_SMALL, ha="center", fontweight="bold") - draw_box( - ax, - 6.0, - 4.0, - 2.0, - 1.2, - "Konsument\nP(full)\nP(mutex)\npobierz()\nV(mutex)\nV(empty)", - fill=GRAY4, - fontsize=5.5, - ) - draw_arrow(ax, 2.2, 4.6, 2.8, 4.65, lw=1.2) - draw_arrow(ax, 6.4, 4.65, 6.0, 4.6, lw=1.2) - - sems = [("mutex = 1", GRAY2), ("empty = N", GRAY1), ("full = 0", GRAY3)] - for i, (s, c) in enumerate(sems): - draw_box( - ax, - 2.0, - 2.5 - i * 0.6, - 4.0, - 0.45, - s, - fill=c, - fontsize=FS_SMALL, - fontweight="bold", - ) - - ax.text( - 4.0, - 0.5, - "KOLEJNOŚĆ: P(empty/full)\nPRZED P(mutex)!\nOdwrotnie = DEADLOCK", - fontsize=5.5, - ha="center", - fontweight="bold", - color="#C62828", - bbox={"boxstyle": "round", "facecolor": "#F8D7DA", "edgecolor": "#C62828"}, - ) - - -def _draw_readers_writers_panel(ax: Axes) -> None: - """Draw the readers-writers panel.""" - ax.set_xlim(0, 8) - ax.set_ylim(0, 7) - ax.set_aspect("auto") - ax.axis("off") - ax.set_title( - "Czytelnicy-Pisarze\n(Readers-Writers)", fontsize=FS, fontweight="bold" - ) - - draw_box( - ax, - 2.5, - 3.5, - 3.0, - 1.5, - "Dane\n(współdzielone)", - fill=GRAY2, - fontsize=FS, - fontweight="bold", - ) - - for i in range(3): - x = 0.3 + i * 1.0 - draw_box( - ax, - x, - 5.5, - 0.8, - 0.7, - f"R{i + 1}", - fill=GRAY4, - fontsize=FS, - fontweight="bold", - ) - draw_arrow(ax, x + 0.4, 5.5, 3.0 + i * 0.5, 5.0, lw=1) - - ax.text( - 1.5, - 6.5, - "Czytelnicy (wielu naraz)", - fontsize=FS_SMALL, - ha="center", - fontweight="bold", - ) - - draw_box( - ax, 5.5, 5.5, 1.5, 0.7, "Pisarz", fill=GRAY5, fontsize=FS, fontweight="bold" - ) - draw_arrow(ax, 6.25, 5.5, 5.0, 5.0, lw=1.5) - ax.text( - 6.25, - 6.5, - "WYŁĄCZNY", - fontsize=FS_SMALL, - ha="center", - fontweight="bold", - color="#C62828", - ) - - rules = [ - "Wielu czytelników = OK", - "Jeden pisarz = wyłączny", - "Czytelnik + Pisarz = ✗", - "Problem: pisarze głodują", - ] - for i, r in enumerate(rules): - ax.text(4.0, 2.5 - i * 0.45, r, fontsize=FS_SMALL, ha="center") - - ax.text( - 4.0, - 0.5, - "Rozwiązanie:\nrw_mutex + count_mutex\n+ zmienna readers", - fontsize=5.5, - ha="center", - bbox={"boxstyle": "round,pad=0.2", "facecolor": GRAY4, "edgecolor": GRAY3}, - ) - - -def _draw_philosophers_panel(ax: Axes) -> None: - """Draw the dining-philosophers panel.""" - ax.set_xlim(0, 8) - ax.set_ylim(0, 7) - ax.set_aspect("auto") - ax.axis("off") - ax.set_title( - "Ucztujący filozofowie\n(Dining Philosophers)", fontsize=FS, fontweight="bold" - ) - - cx, cy, r = 4.0, 3.8, 1.8 - table = plt.Circle((cx, cy), 0.8, fill=True, facecolor=GRAY2, edgecolor=LN, lw=1.5) - ax.add_patch(table) - ax.text(cx, cy, "Stół", fontsize=FS, ha="center", fontweight="bold") - - for i in range(5): - angle = np.pi / 2 + i * 2 * np.pi / 5 - px = cx + r * np.cos(angle) - py = cy + r * np.sin(angle) - circle = plt.Circle( - (px, py), 0.35, fill=True, facecolor=GRAY1, edgecolor=LN, lw=1.2 - ) - ax.add_patch(circle) - ax.text( - px, py, f"F{i}", ha="center", va="center", fontsize=FS, fontweight="bold" - ) - - fork_angle = np.pi / 2 + (i + 0.5) * 2 * np.pi / 5 - fx = cx + (r * 0.6) * np.cos(fork_angle) - fy = cy + (r * 0.6) * np.sin(fork_angle) - ax.plot( - [fx - 0.1, fx + 0.1], - [fy - 0.15, fy + 0.15], - color=LN, - lw=2.5, - solid_capstyle="round", - ) - ax.text(fx + 0.2, fy + 0.15, f"w{i}", fontsize=5, color="#555555") - - rules = [ - "Jedzenie = 2 widelce", - "Naiwne → DEADLOCK", - "Fix: F4 bierze odwrotnie", - "Alt: semafor(4)", - ] - for i, r in enumerate(rules): - ax.text(4.0, 1.2 - i * 0.35, r, fontsize=FS_SMALL, ha="center") - - -# ============================================================ -# 14. Bounded buffer + readers-writers + philosophers -# ============================================================ -def gen_classic_problems() -> None: - """Gen classic problems.""" - fig, axes = plt.subplots(1, 3, figsize=(12, 5)) - fig.suptitle( - "Klasyczne problemy synchronizacji", fontsize=FS_TITLE, fontweight="bold" - ) - - _draw_bounded_buffer_panel(axes[0]) - _draw_readers_writers_panel(axes[1]) - _draw_philosophers_panel(axes[2]) - - fig.tight_layout(rect=[0, 0, 1, 0.88]) - save_fig(fig, "q9_classic_problems.png") - - -# ============================================================ -# 15. Sync mechanisms comparison + mutex/sem/spinlock -# ============================================================ -def gen_sync_comparison() -> None: - """Gen sync comparison.""" - fig, axes = plt.subplots(2, 1, figsize=(9, 7)) - - # Top: comparison table - ax = axes[0] - ax.set_xlim(0, 11.5) - ax.set_ylim(-5, 1) - ax.set_aspect("auto") - ax.axis("off") - ax.set_title( - "Mechanizmy synchronizacji — porównanie", - fontsize=FS_TITLE, - fontweight="bold", - pad=10, - ) - - headers = ["Mechanizm", "Opis", "Kiedy używać"] - col_w = [2.5, 4.5, 4.0] - rows = [ - ["Mutex", "Zamek: 1 wątek w sekcji", "Sekcja krytyczna"], - ["Semafor(n)", "Licznik: max n wątków", "Ograniczone zasoby (n miejsc)"], - ["Monitor", "Obiekt z wbudowanym mutex", "Java synchronized"], - ["Cond. Variable", "wait()/signal() na warunek", "Producent-konsument"], - ["Spinlock", "Aktywne czekanie (busy-wait)", "Bardzo krótkie sekcje (<1 μs)"], - ["RW Lock", "Wielu czytelników LUB 1 pisarz", "Bazy danych, cache"], - ["Barrier", "Czekaj aż wszyscy dotrą", "Obliczenia równoległe"], - ] - draw_table( - ax, headers, rows, x0=0.25, y0=0.5, col_widths=col_w, row_h=0.5, fontsize=7 - ) - - # Bottom: mutex vs semafor vs spinlock - ax = axes[1] - ax.set_xlim(0, 12) - ax.set_ylim(0, 5) - ax.set_aspect("auto") - ax.axis("off") - ax.set_title( - "Mutex vs Semafor vs Spinlock", fontsize=FS_TITLE, fontweight="bold", pad=5 - ) - - # Mutex - draw_box(ax, 0.3, 2.5, 3.5, 2.0, "", fill=GRAY4) - ax.text(2.05, 4.2, "MUTEX", fontsize=FS_LABEL, fontweight="bold", ha="center") - ax.text(2.05, 3.6, "= klucz do łazienki\n(1 osoba)", fontsize=FS, ha="center") - ax.text( - 2.05, - 2.8, - "Wątek ZASYPIA gdy czeka\nOS go obudzi (~μs)", - fontsize=FS_SMALL, - ha="center", - style="italic", - ) - - # Semafor - draw_box(ax, 4.3, 2.5, 3.5, 2.0, "", fill=GRAY1) - ax.text(6.05, 4.2, "SEMAFOR(n)", fontsize=FS_LABEL, fontweight="bold", ha="center") - ax.text( - 6.05, 3.6, "= parking na n miejsc\n(n wątków naraz)", fontsize=FS, ha="center" - ) - ax.text( - 6.05, - 2.8, - "Semafor(1) = mutex\nP() = zmniejsz, V() = zwiększ", - fontsize=FS_SMALL, - ha="center", - style="italic", - ) - - # Spinlock - draw_box(ax, 8.3, 2.5, 3.5, 2.0, "", fill=GRAY2) - ax.text(10.05, 4.2, "SPINLOCK", fontsize=FS_LABEL, fontweight="bold", ha="center") - ax.text( - 10.05, 3.6, "= obrotowe drzwi\n(kręcisz się w kółko)", fontsize=FS, ha="center" - ) - ax.text( - 10.05, - 2.8, - "Wątek KRĘCI się w pętli\nLepszy gdy sekcja < 1 μs", - fontsize=FS_SMALL, - ha="center", - style="italic", - ) - - # Dividing rule - ax.text( - 6.0, - 1.5, - "Reguła kciuka: sekcja > 1 μs → MUTEX | " - "sekcja < 1 μs → SPINLOCK | n jednocześnie → SEMAFOR(n)", - fontsize=FS, - ha="center", - fontweight="bold", - bbox={"boxstyle": "round,pad=0.3", "facecolor": GRAY4, "edgecolor": GRAY3}, - ) - - fig.tight_layout() - save_fig(fig, "q9_sync_comparison.png") - - -# ============================================================ -# 16. Semaphore concept diagram -# ============================================================ -def gen_semaphore_concept() -> None: - """Gen semaphore concept.""" - fig, ax = plt.subplots(figsize=(6, 3)) - ax.set_xlim(0, 10) - ax.set_ylim(0, 5) - ax.set_aspect("auto") - ax.axis("off") - ax.set_title( - "Semafor — koncepcja (parking na 3 miejsca)", - fontsize=FS_TITLE, - fontweight="bold", - pad=10, - ) - - # Parking slots - for i in range(3): - x = 2.0 + i * 2.0 - occupied = i < OCCUPIED_SLOTS - fill = GRAY3 if occupied else "white" - label = f"Wątek {i + 1}" if occupied else "(wolne)" - draw_box( - ax, - x, - 2.5, - 1.5, - 1.2, - label, - fill=fill, - fontsize=FS, - fontweight="bold" if occupied else "normal", - rounded=False, - ) - - ax.text( - 5.0, - 4.2, - "semafor(3): counter = 1 (jedno wolne miejsce)", - fontsize=FS, - ha="center", - fontweight="bold", - ) - - # Waiting thread - draw_box( - ax, - 0.2, - 0.5, - 1.5, - 0.8, - "Wątek 4\nP() → czeka", - fill="#F8D7DA", - fontsize=FS_SMALL, - ) - draw_arrow(ax, 1.7, 0.9, 2.0, 2.5, lw=1.2, color="#C62828") - - ax.text( - 5.0, - 0.6, - "P() = counter-- (jeśli 0 → czekaj)\nV() = counter++ (obudź czekającego)", - fontsize=FS, - ha="center", - family="monospace", - bbox={"boxstyle": "round,pad=0.2", "facecolor": GRAY4, "edgecolor": GRAY3}, - ) - - save_fig(fig, "q9_semaphore_concept.png") - - # ============================================================ # MAIN — generate all # ============================================================ diff --git a/python_pkg/praca_magisterska_video/generate_images/generate_scheduling_diagrams.py b/python_pkg/praca_magisterska_video/generate_images/generate_scheduling_diagrams.py old mode 100755 new mode 100644 index ea7aeef..b68a5b1 --- a/python_pkg/praca_magisterska_video/generate_images/generate_scheduling_diagrams.py +++ b/python_pkg/praca_magisterska_video/generate_images/generate_scheduling_diagrams.py @@ -2,11 +2,12 @@ """Generate diagrams for PYTANIE 17: Szeregowanie zadań (Scheduling). Diagrams: - 1. Graham notation \u03b1|β|\u03b3 visual mnemonic map + 1. Graham notation α|β|γ visual mnemonic map 2. Johnson's algorithm Gantt chart (F2||Cmax example) 3. SPT vs LPT comparison Gantt (1||ΣCⱼ) 4. Flow shop vs Job shop visual comparison 5. Scheduling complexity landscape + 6. EDD example (1 || Lmax) All: A4-compatible, B&W, 300 DPI, laser-printer-friendly. """ @@ -14,1452 +15,46 @@ All: A4-compatible, B&W, 300 DPI, laser-printer-friendly. from __future__ import annotations import logging -from typing import TYPE_CHECKING import matplotlib as mpl mpl.use("Agg") -from pathlib import Path -import matplotlib.patches as mpatches -from matplotlib.patches import FancyBboxPatch -import matplotlib.pyplot as plt - -if TYPE_CHECKING: - from matplotlib.axes import Axes +# Re-export common utilities for backward compatibility +from python_pkg.praca_magisterska_video.generate_images._sched_common import ( # noqa: F401 + BG, + DPI, + FONTWEIGHT_THRESHOLD, + FS, + FS_TITLE, + GRAY1, + GRAY2, + GRAY3, + GRAY4, + GRAY5, + LN, + MIN_COLUMN_INDEX, + OUTPUT_DIR, + draw_arrow, + draw_box, +) +from python_pkg.praca_magisterska_video.generate_images._sched_complexity_edd import ( + draw_complexity_map, + draw_edd_example, +) +from python_pkg.praca_magisterska_video.generate_images._sched_graham import ( + draw_graham_notation, +) +from python_pkg.praca_magisterska_video.generate_images._sched_johnson import ( + draw_johnson_gantt, +) +from python_pkg.praca_magisterska_video.generate_images._sched_spt_flow_job import ( + draw_flow_vs_job, + draw_spt_comparison, +) _logger = logging.getLogger(__name__) -DPI = 300 -BG = "white" -LN = "black" -FS = 8 -FS_TITLE = 11 -OUTPUT_DIR = str(Path(__file__).resolve().parent / "img") -Path(OUTPUT_DIR).mkdir(parents=True, exist_ok=True) - -GRAY1 = "#E8E8E8" -GRAY2 = "#D0D0D0" -GRAY3 = "#B8B8B8" -GRAY4 = "#F5F5F5" -GRAY5 = "#C0C0C0" - -MIN_COLUMN_INDEX = 3 -FONTWEIGHT_THRESHOLD = 3 - - -def draw_box( - ax: Axes, - x: float, - y: float, - w: float, - h: float, - text: str, - fill: str = "white", - lw: float = 1.2, - fontsize: float = FS, - fontweight: str = "normal", - ha: str = "center", - va: str = "center", - *, - rounded: bool = True, -) -> None: - """Draw box.""" - if rounded: - rect = FancyBboxPatch( - (x, y), w, h, boxstyle="round,pad=0.05", lw=lw, edgecolor=LN, facecolor=fill - ) - else: - rect = mpatches.Rectangle((x, y), w, h, lw=lw, edgecolor=LN, facecolor=fill) - ax.add_patch(rect) - ax.text( - x + w / 2, - y + h / 2, - text, - ha=ha, - va=va, - fontsize=fontsize, - fontweight=fontweight, - wrap=True, - ) - - -def draw_arrow( - ax: Axes, - x1: float, - y1: float, - x2: float, - y2: float, - lw: float = 1.2, - style: str = "->", - color: str = LN, -) -> None: - """Draw arrow.""" - ax.annotate( - "", - xy=(x2, y2), - xytext=(x1, y1), - arrowprops={"arrowstyle": style, "color": color, "lw": lw}, - ) - - -# ============================================================ -# 1. GRAHAM NOTATION alpha|β|gamma — MNEMONIC MAP -# ============================================================ -def draw_graham_notation() -> None: - """Draw graham notation.""" - _fig, ax = plt.subplots(1, 1, figsize=(8.27, 10)) - ax.set_xlim(0, 10) - ax.set_ylim(0, 14) - ax.set_aspect("equal") - ax.axis("off") - ax.set_title( - "Notacja Grahama \u03b1 | β | \u03b3 — Mapa mnemoniczna", - fontsize=FS_TITLE + 1, - fontweight="bold", - pad=12, - ) - - _draw_graham_formula_bar(ax) - _draw_graham_alpha_beta(ax) - _draw_graham_lower(ax) - - plt.tight_layout() - plt.savefig( - str(Path(OUTPUT_DIR) / "scheduling_graham_notation.png"), - dpi=DPI, - bbox_inches="tight", - facecolor=BG, - ) - plt.close() - _logger.info(" ✓ scheduling_graham_notation.png") - - -def _draw_graham_formula_bar(ax: Axes) -> None: - """Draw the top alpha|beta|gamma formula bar.""" - bar_y = 12.5 - bar_h = 1.0 - # alpha box - rect = FancyBboxPatch( - (0.5, bar_y), - 2.5, - bar_h, - boxstyle="round,pad=0.08", - lw=2, - edgecolor=LN, - facecolor=GRAY1, - ) - ax.add_patch(rect) - ax.text( - 1.75, - bar_y + bar_h / 2, - "\u03b1", - fontsize=20, - fontweight="bold", - ha="center", - va="center", - ) - ax.text( - 1.75, - bar_y - 0.25, - "MASZYNY", - fontsize=8, - fontweight="bold", - ha="center", - va="top", - color="#444444", - ) - - # separator | - ax.text( - 3.3, - bar_y + bar_h / 2, - "|", - fontsize=24, - fontweight="bold", - ha="center", - va="center", - ) - - # β box - rect = FancyBboxPatch( - (3.7, bar_y), - 2.5, - bar_h, - boxstyle="round,pad=0.08", - lw=2, - edgecolor=LN, - facecolor=GRAY2, - ) - ax.add_patch(rect) - ax.text( - 4.95, - bar_y + bar_h / 2, - "β", - fontsize=20, - fontweight="bold", - ha="center", - va="center", - ) - ax.text( - 4.95, - bar_y - 0.25, - "OGRANICZENIA", - fontsize=8, - fontweight="bold", - ha="center", - va="top", - color="#444444", - ) - - # separator | - ax.text( - 6.5, - bar_y + bar_h / 2, - "|", - fontsize=24, - fontweight="bold", - ha="center", - va="center", - ) - - # gamma box - rect = FancyBboxPatch( - (6.9, bar_y), - 2.5, - bar_h, - boxstyle="round,pad=0.08", - lw=2, - edgecolor=LN, - facecolor=GRAY3, - ) - ax.add_patch(rect) - ax.text( - 8.15, - bar_y + bar_h / 2, - "\u03b3", - fontsize=20, - fontweight="bold", - ha="center", - va="center", - ) - ax.text( - 8.15, - bar_y - 0.25, - "CEL", - fontsize=8, - fontweight="bold", - ha="center", - va="top", - color="#444444", - ) - - -def _draw_graham_alpha_beta(ax: Axes) -> None: - """Draw alpha (machines) and beta (constraints) sections.""" - start_x = 0.3 - col_w = 1.28 - - # === SECTION alpha: MACHINES === - sec_y = 11.5 - ax.text( - 0.3, - sec_y, - '\u03b1 — „1 Prawdziwy Quasi-Rycerz Forsuje Jaskinię Orków"', - fontsize=8, - fontweight="bold", - va="top", - style="italic", - color="#333333", - ) - - alpha_items = [ - ("1", "jedna maszyna", "●", GRAY4), - ("Pm", "identyczne Parallel", "●●●", GRAY1), - ("Qm", "Quasi-uniform\n(różne prędkości)", "●●◐", GRAY4), - ("Rm", "Random unrelated\n(czasy per para)", "●◆▲", GRAY1), - ("Fm", "Flow shop\n(ta sama kolejność)", "→→→", GRAY2), - ("Jm", "Job shop\n(indyw. trasy)", "↗↙↘", GRAY4), - ("Om", "Open shop\n(dowolna kolej.)", "?→?", GRAY1), - ] - - col_w = 1.28 - box_h_a = 1.1 - start_x = 0.3 - start_y = 9.6 - - for i, (symbol, desc, icon, fill) in enumerate(alpha_items): - x = start_x + i * col_w - y = start_y - rect = FancyBboxPatch( - (x, y), - col_w - 0.1, - box_h_a, - boxstyle="round,pad=0.04", - lw=1, - edgecolor=LN, - facecolor=fill, - ) - ax.add_patch(rect) - ax.text( - x + (col_w - 0.1) / 2, - y + box_h_a - 0.15, - symbol, - ha="center", - va="top", - fontsize=9, - fontweight="bold", - ) - ax.text( - x + (col_w - 0.1) / 2, - y + box_h_a / 2 - 0.1, - desc, - ha="center", - va="center", - fontsize=5.5, - ) - ax.text( - x + (col_w - 0.1) / 2, y + 0.12, icon, ha="center", va="bottom", fontsize=7 - ) - - # Complexity arrow under alpha - arr_y = 9.35 - ax.annotate( - "", - xy=(9.0, arr_y), - xytext=(0.5, arr_y), - arrowprops={"arrowstyle": "->", "color": "#666666", "lw": 1.5}, - ) - ax.text( - 4.8, - arr_y - 0.18, - "rosnąca złożoność →", - ha="center", - fontsize=6, - color="#666666", - ) - - # === SECTION β: CONSTRAINTS === - sec_y2 = 8.9 - ax.text( - 0.3, - sec_y2, - "β — „Robak Daje Deadline: Przerwy Poprzedzają Pojedyncze Setup'y\"", - fontsize=8, - fontweight="bold", - va="top", - style="italic", - color="#333333", - ) - - beta_items = [ - ("rⱼ", "release\ndates", "Robak\ndostępne\nod czasu rⱼ", GRAY1), - ("dⱼ", "due\ndates", "Daje\ntermin soft\n(kara za spóźn.)", GRAY4), - ("d̄ⱼ", "dead-\nlines", "Deadline\ntermin hard\n(musi dotrzymać)", GRAY1), - ("pmtn", "preemp-\ntion", "Przerwy\nmożna\nprzerwać", GRAY2), - ("prec", "prece-\ndencje", "Poprzedzają\nA->B (DAG)", GRAY4), - ("pⱼ=1", "unit\ntime", "Pojedyncze\nwszystkie = 1", GRAY1), - ("sⱼₖ", "setup\ntimes", "Setup'y\nprzezbrojenie\nmiędzy j->k", GRAY4), - ] - - start_y2 = 7.0 - box_h_b = 1.4 - - for i, (symbol, _label, desc, fill) in enumerate(beta_items): - x = start_x + i * col_w - y = start_y2 - rect = FancyBboxPatch( - (x, y), - col_w - 0.1, - box_h_b, - boxstyle="round,pad=0.04", - lw=1, - edgecolor=LN, - facecolor=fill, - ) - ax.add_patch(rect) - ax.text( - x + (col_w - 0.1) / 2, - y + box_h_b - 0.12, - symbol, - ha="center", - va="top", - fontsize=9, - fontweight="bold", - ) - ax.text( - x + (col_w - 0.1) / 2, - y + box_h_b / 2 - 0.05, - desc, - ha="center", - va="center", - fontsize=5, - ) - - -def _draw_graham_lower(ax: Axes) -> None: - """Draw gamma criteria, examples, and footer sections.""" - start_x = 0.3 - - # === SECTION gamma: CRITERIA === - sec_y3 = 6.5 - ax.text( - 0.3, - sec_y3, - '\u03b3 — „Ciężki Sum Ważony Lata, Tardiness Uderza"', - fontsize=8, - fontweight="bold", - va="top", - style="italic", - color="#333333", - ) - - gamma_items = [ - ("Cmax", "makespan\nmax(Cⱼ)", "Jak długo\ntrwa WSZYSTKO?", GRAY2), - ("ΣCⱼ", "suma\nukończeń", "Średni czas\noczekiwania?", GRAY4), - ("ΣwⱼCⱼ", "ważona\nsuma", "Priorytety\nzadań?", GRAY1), - ("Lmax", "max\nopóźnienie", "Najgorsze\nspóźnienie?", GRAY2), - ("ΣTⱼ", "suma\nspóźnień", "Łączne\nspóźnienia?", GRAY4), - ("ΣUⱼ", "liczba\nspóźnionych", "Ile spóźnionych\nzadań?", GRAY1), - ] - - start_y3 = 4.5 - box_h_g = 1.4 - col_w_g = 1.5 - - for i, (symbol, label, question, fill) in enumerate(gamma_items): - x = start_x + i * col_w_g - y = start_y3 - rect = FancyBboxPatch( - (x, y), - col_w_g - 0.1, - box_h_g, - boxstyle="round,pad=0.04", - lw=1, - edgecolor=LN, - facecolor=fill, - ) - ax.add_patch(rect) - ax.text( - x + (col_w_g - 0.1) / 2, - y + box_h_g - 0.1, - symbol, - ha="center", - va="top", - fontsize=9, - fontweight="bold", - ) - ax.text( - x + (col_w_g - 0.1) / 2, - y + box_h_g / 2 - 0.05, - label, - ha="center", - va="center", - fontsize=6, - ) - ax.text( - x + (col_w_g - 0.1) / 2, - y + 0.15, - f'„{question}"', - ha="center", - va="bottom", - fontsize=5, - style="italic", - ) - - # === BOTTOM: Example + Optimal methods === - ex_y = 3.5 - ax.text( - 0.3, - ex_y, - "Przykłady zapisu i optymalne metody:", - fontsize=8, - fontweight="bold", - va="top", - ) - - examples = [ - ("1 || ΣCⱼ", "SPT (najkrótsze\nnajpierw)", "O(n log n)", GRAY1), - ("1 || Lmax", "EDD (najwcześniejszy\ntermin)", "O(n log n)", GRAY4), - ("F2 || Cmax", "Algorytm\nJohnsona", "O(n log n)", GRAY2), - ("Pm || Cmax", "LPT heurystyka\n(NP-trudny!)", "NP-hard", GRAY3), - ("Jm || Cmax", "Branch & Bound\n(NP-trudny!)", "NP-hard", GRAY5), - ] - - ex_start_y = 1.8 - ex_box_w = 1.72 - ex_box_h = 1.4 - - for i, (notation, method, complexity, fill) in enumerate(examples): - x = start_x + i * (ex_box_w + 0.1) - y = ex_start_y - rect = FancyBboxPatch( - (x, y), - ex_box_w, - ex_box_h, - boxstyle="round,pad=0.04", - lw=1.2, - edgecolor=LN, - facecolor=fill, - ) - ax.add_patch(rect) - ax.text( - x + ex_box_w / 2, - y + ex_box_h - 0.12, - notation, - ha="center", - va="top", - fontsize=8, - fontweight="bold", - fontfamily="monospace", - ) - ax.text( - x + ex_box_w / 2, - y + ex_box_h / 2 - 0.05, - method, - ha="center", - va="center", - fontsize=6, - ) - ax.text( - x + ex_box_w / 2, - y + 0.12, - complexity, - ha="center", - va="bottom", - fontsize=6.5, - fontweight="bold", - color="#555555", - ) - - # Footer mnemonic summary - ax.text( - 5.0, - 0.8, - '„\u03b1|β|\u03b3 = Maszyny | Ograniczenia | Cel"', - ha="center", - fontsize=9, - fontweight="bold", - style="italic", - color="#333333", - bbox={ - "boxstyle": "round,pad=0.3", - "facecolor": GRAY4, - "edgecolor": GRAY3, - "lw": 1, - }, - ) - - ax.text( - 5.0, - 0.2, - "\u03b1: ILE maszyn i JAKIE? " - "β: JAKIE ograniczenia zadań? " - "\u03b3: CO minimalizujemy?", - ha="center", - fontsize=7, - color="#555555", - ) - - -# ============================================================ -# 2. JOHNSON'S ALGORITHM GANTT CHART -# ============================================================ -def draw_johnson_gantt() -> None: - """Draw johnson gantt.""" - _fig, axes = plt.subplots( - 2, 1, figsize=(8.27, 7), gridspec_kw={"height_ratios": [1, 1.8]} - ) - - _draw_johnson_decision_table(axes[0]) - _draw_johnson_gantt_chart(axes[1]) - - plt.tight_layout() - plt.savefig( - str(Path(OUTPUT_DIR) / "scheduling_johnson_gantt.png"), - dpi=DPI, - bbox_inches="tight", - facecolor=BG, - ) - plt.close() - _logger.info(" ✓ scheduling_johnson_gantt.png") - - -def _draw_johnson_decision_table(ax: Axes) -> None: - """Draw the Johnson algorithm decision table.""" - ax.set_xlim(0, 10) - ax.set_ylim(0, 5) - ax.set_aspect("equal") - ax.axis("off") - ax.set_title( - "Algorytm Johnsona (F2 || Cmax) — Decyzja + Diagram Gantta", - fontsize=FS_TITLE, - fontweight="bold", - pad=10, - ) - - # Task table - tasks = ["J1", "J2", "J3", "J4", "J5"] - a_times = [4, 2, 6, 1, 3] - b_times = [5, 3, 2, 7, 4] - min_vals = [min(a, b) for a, b in zip(a_times, b_times, strict=False)] - min_on = ["M1" if a <= b else "M2" for a, b in zip(a_times, b_times, strict=False)] - assign = ["POCZątek" if m == "M1" else "KONIEC" for m in min_on] - - # Draw table - col_w_t = 1.3 - row_h = 0.55 - headers = ["Zadanie", "aⱼ (M1)", "bⱼ (M2)", "min", "min na", "Przydziel"] - table_x = 0.8 - table_y = 3.8 - - for j, hdr in enumerate(headers): - x = table_x + j * col_w_t - rect = mpatches.Rectangle( - (x, table_y), col_w_t, row_h, lw=1, edgecolor=LN, facecolor=GRAY2 - ) - ax.add_patch(rect) - ax.text( - x + col_w_t / 2, - table_y + row_h / 2, - hdr, - ha="center", - va="center", - fontsize=6.5, - fontweight="bold", - ) - - for i in range(5): - row_data = [ - tasks[i], - str(a_times[i]), - str(b_times[i]), - str(min_vals[i]), - min_on[i], - assign[i], - ] - for j, val in enumerate(row_data): - x = table_x + j * col_w_t - y = table_y - (i + 1) * row_h - fill_c = GRAY1 if min_on[i] == "M1" else GRAY4 - if j == MIN_COLUMN_INDEX: # min column - highlight - fill_c = GRAY3 - rect = mpatches.Rectangle( - (x, y), col_w_t, row_h, lw=0.8, edgecolor=LN, facecolor=fill_c - ) - ax.add_patch(rect) - fw = "bold" if j >= FONTWEIGHT_THRESHOLD else "normal" - ax.text( - x + col_w_t / 2, - y + row_h / 2, - val, - ha="center", - va="center", - fontsize=6.5, - fontweight=fw, - ) - - # Sorting result - result_y = 0.7 - ax.text( - 5.0, - result_y + 0.4, - "Sortuj → POCZĄTEK ↑aⱼ: J4(1), J2(2), J5(3), J1(4) | KONIEC ↓bⱼ: J3(2)", - ha="center", - fontsize=7, - color="#333333", - ) - ax.text( - 5.0, - result_y, - "Optymalna kolejność: J4 → J2 → J5 → J1 → J3", - ha="center", - fontsize=9, - fontweight="bold", - bbox={ - "boxstyle": "round,pad=0.2", - "facecolor": GRAY1, - "edgecolor": LN, - "lw": 1.2, - }, - ) - - -def _draw_johnson_gantt_chart(ax2: Axes) -> None: - """Draw the Johnson algorithm Gantt chart.""" - ax2.set_xlim(-1, 24) - ax2.set_ylim(-1, 4) - ax2.axis("off") - - # Machines labels - m1_y = 2.5 - m2_y = 0.8 - bar_h = 0.9 - - ax2.text( - -0.8, - m1_y + bar_h / 2, - "M1", - ha="center", - va="center", - fontsize=11, - fontweight="bold", - ) - ax2.text( - -0.8, - m2_y + bar_h / 2, - "M2", - ha="center", - va="center", - fontsize=11, - fontweight="bold", - ) - - # Schedule: J4 → J2 → J5 → J1 → J3 - order = ["J4", "J2", "J5", "J1", "J3"] - a_ord = [1, 2, 3, 4, 6] # M1 times in order - b_ord = [7, 3, 4, 5, 2] # M2 times in order - fills = [GRAY1, GRAY2, GRAY4, GRAY3, GRAY5] - hatches = ["", "///", "", "\\\\\\", "xxx"] - - # M1 schedule - m1_starts = [] - t = 0 - for a in a_ord: - m1_starts.append(t) - t += a - m1_ends = [s + a for s, a in zip(m1_starts, a_ord, strict=False)] - - # M2 schedule (must wait for M1 finish AND previous M2 finish) - m2_starts = [] - m2_ends = [] - prev_m2_end = 0 - for i, b in enumerate(b_ord): - start = max(m1_ends[i], prev_m2_end) - m2_starts.append(start) - m2_ends.append(start + b) - prev_m2_end = start + b - - # Draw M1 bars - for i in range(5): - rect = mpatches.Rectangle( - (m1_starts[i], m1_y), - a_ord[i], - bar_h, - lw=1.2, - edgecolor=LN, - facecolor=fills[i], - hatch=hatches[i], - ) - ax2.add_patch(rect) - ax2.text( - m1_starts[i] + a_ord[i] / 2, - m1_y + bar_h / 2, - f"{order[i]}\n({a_ord[i]})", - ha="center", - va="center", - fontsize=7, - fontweight="bold", - ) - - # Draw M2 bars - for i in range(5): - rect = mpatches.Rectangle( - (m2_starts[i], m2_y), - b_ord[i], - bar_h, - lw=1.2, - edgecolor=LN, - facecolor=fills[i], - hatch=hatches[i], - ) - ax2.add_patch(rect) - ax2.text( - m2_starts[i] + b_ord[i] / 2, - m2_y + bar_h / 2, - f"{order[i]}\n({b_ord[i]})", - ha="center", - va="center", - fontsize=7, - fontweight="bold", - ) - - # Draw idle regions on M2 - idle_starts = [0] - idle_ends = [m2_starts[0]] - for i in range(1, 5): - if m2_starts[i] > m2_ends[i - 1]: - idle_starts.append(m2_ends[i - 1]) - idle_ends.append(m2_starts[i]) - - for s, e in zip(idle_starts, idle_ends, strict=False): - if e > s: - rect = mpatches.Rectangle( - (s, m2_y), - e - s, - bar_h, - lw=0.5, - edgecolor="#AAAAAA", - facecolor="white", - linestyle="--", - ) - ax2.add_patch(rect) - ax2.text( - s + (e - s) / 2, - m2_y + bar_h / 2, - "idle", - ha="center", - va="center", - fontsize=5, - color="#999999", - ) - - # Time axis - ax_y = m2_y - 0.15 - ax2.plot([0, 23], [ax_y, ax_y], color=LN, lw=0.8) - for t in range(0, 24, 2): - ax2.plot([t, t], [ax_y - 0.08, ax_y + 0.08], color=LN, lw=0.8) - ax2.text(t, ax_y - 0.25, str(t), ha="center", va="top", fontsize=6) - ax2.text(11.5, ax_y - 0.55, "czas", ha="center", fontsize=7) - - # Cmax annotation - ax2.annotate( - f"Cmax = {m2_ends[-1]}", - xy=(m2_ends[-1], m2_y + bar_h), - xytext=(m2_ends[-1] + 0.5, m2_y + bar_h + 0.6), - fontsize=10, - fontweight="bold", - color="#333333", - arrowprops={"arrowstyle": "->", "color": "#333333", "lw": 1.5}, - ) - - # Mnemonic at bottom - ax2.text( - 11, - -0.7, - '„Krótki na M1 → START (szybko karmi M2)' - ' Krótki na M2 → KONIEC' - ' (szybko kończy)"', - ha="center", - fontsize=7.5, - fontweight="bold", - style="italic", - bbox={ - "boxstyle": "round,pad=0.3", - "facecolor": GRAY4, - "edgecolor": GRAY3, - "lw": 0.8, - }, - ) - - -# ============================================================ -# 3. SPT vs LPT COMPARISON (1 || ΣCⱼ) -# ============================================================ -def draw_spt_comparison() -> None: - """Draw spt comparison.""" - fig, axes = plt.subplots(2, 1, figsize=(8.27, 5.5)) - - tasks_orig = [("J1", 5), ("J2", 3), ("J3", 8), ("J4", 2), ("J5", 6)] - - spt_order = sorted(tasks_orig, key=lambda x: x[1]) - lpt_order = sorted(tasks_orig, key=lambda x: -x[1]) - - fills_map = {"J1": GRAY1, "J2": GRAY2, "J3": GRAY3, "J4": GRAY4, "J5": GRAY5} - hatch_map = {"J1": "", "J2": "///", "J3": "xxx", "J4": "", "J5": "\\\\\\"} - - for _idx, (ax, order_list, title, is_optimal) in enumerate( - [ - (axes[0], spt_order, "SPT (Shortest Processing Time) — OPTYMALNE", True), - (axes[1], lpt_order, "LPT (Longest Processing Time) — gorsze!", False), - ] - ): - ax.set_xlim(-2, 26) - ax.set_ylim(-0.5, 2.5) - ax.axis("off") - color = "#222222" if is_optimal else "#666666" - marker = "✓" if is_optimal else "✗" - ax.set_title( - f"{marker} {title}", - fontsize=9, - fontweight="bold", - loc="left", - color=color, - pad=5, - ) - - bar_y = 1.0 - bar_h = 0.8 - t = 0 - completions = [] - - for name, duration in order_list: - rect = mpatches.Rectangle( - (t, bar_y), - duration, - bar_h, - lw=1.2, - edgecolor=LN, - facecolor=fills_map[name], - hatch=hatch_map[name], - ) - ax.add_patch(rect) - ax.text( - t + duration / 2, - bar_y + bar_h / 2, - f"{name}\n({duration})", - ha="center", - va="center", - fontsize=7, - fontweight="bold", - ) - t += duration - completions.append(t) - - # Completion time marker - ax.plot([t, t], [bar_y - 0.15, bar_y], color=LN, lw=0.8) - ax.text( - t, - bar_y - 0.25, - f"C={t}", - ha="center", - va="top", - fontsize=6, - color="#555555", - ) - - total = sum(completions) - # Time axis - ax.plot([0, 25], [bar_y - 0.05, bar_y - 0.05], color=LN, lw=0.5) - - # Sum annotation - comp_str = " + ".join(str(c) for c in completions) - ax.text( - 25, - bar_y + bar_h / 2, - f"ΣCⱼ = {comp_str}\n = {total}", - ha="left", - va="center", - fontsize=7, - fontweight="bold" if is_optimal else "normal", - color=color, - bbox={ - "boxstyle": "round,pad=0.2", - "facecolor": GRAY1 if is_optimal else "white", - "edgecolor": color, - "lw": 1, - }, - ) - - # Bottom annotation - fig.text( - 0.5, - 0.02, - '„Short People To the front"' - ' — krótkie najpierw,' - ' jak niskie osoby w zdjęciu klasowym', - ha="center", - fontsize=8, - fontweight="bold", - style="italic", - color="#444444", - ) - - plt.tight_layout(rect=[0, 0.05, 1, 1]) - plt.savefig( - str(Path(OUTPUT_DIR) / "scheduling_spt_comparison.png"), - dpi=DPI, - bbox_inches="tight", - facecolor=BG, - ) - plt.close() - _logger.info(" ✓ scheduling_spt_comparison.png") - - -# ============================================================ -# 4. FLOW SHOP vs JOB SHOP -# ============================================================ -def draw_flow_vs_job() -> None: - """Draw flow vs job.""" - _fig, axes = plt.subplots(1, 2, figsize=(8.27, 4.5)) - - _draw_flow_shop(axes[0]) - _draw_job_shop(axes[1]) - - plt.tight_layout() - plt.savefig( - str(Path(OUTPUT_DIR) / "scheduling_flow_vs_job.png"), - dpi=DPI, - bbox_inches="tight", - facecolor=BG, - ) - plt.close() - _logger.info(" ✓ scheduling_flow_vs_job.png") - - -def _draw_flow_shop(ax: Axes) -> None: - """Draw the Flow Shop diagram.""" - ax.set_xlim(0, 6) - ax.set_ylim(0, 6) - ax.set_aspect("equal") - ax.axis("off") - ax.set_title("Flow Shop (Fm)", fontsize=10, fontweight="bold", pad=8) - - # Machines in a row - machines_x = [1, 3, 5] - machines_y = 3 - mach_r = 0.4 - - for i, mx in enumerate(machines_x): - circle = plt.Circle( - (mx, machines_y), mach_r, facecolor=GRAY2, edgecolor=LN, lw=1.5 - ) - ax.add_patch(circle) - ax.text( - mx, - machines_y, - f"M{i + 1}", - ha="center", - va="center", - fontsize=9, - fontweight="bold", - ) - - # Arrows between machines - for i in range(len(machines_x) - 1): - draw_arrow( - ax, - machines_x[i] + mach_r + 0.05, - machines_y, - machines_x[i + 1] - mach_r - 0.05, - machines_y, - lw=2, - ) - - # Jobs all flowing the same way - jobs_flow = ["J1", "J2", "J3"] - for _j, (job, y_off) in enumerate(zip(jobs_flow, [0.8, 0, -0.8], strict=False)): - ax.text( - 0.2, - machines_y + y_off, - job, - ha="center", - va="center", - fontsize=7, - fontweight="bold", - bbox={"boxstyle": "round,pad=0.15", "facecolor": GRAY1, "edgecolor": LN}, - ) - # Dashed flow line - ax.annotate( - "", - xy=(5.5, machines_y + y_off * 0.3), - xytext=(0.5, machines_y + y_off), - arrowprops={ - "arrowstyle": "->", - "color": "#888888", - "lw": 0.8, - "linestyle": "dashed", - }, - ) - - ax.text( - 3, - 1.2, - "Wszystkie zadania:\nM1 → M2 → M3", - ha="center", - va="center", - fontsize=8, - bbox={"boxstyle": "round,pad=0.3", "facecolor": GRAY4, "edgecolor": GRAY3}, - ) - - ax.text( - 3, - 0.4, - "Jak taśma montażowa", - ha="center", - fontsize=7, - style="italic", - color="#666666", - ) - - -def _draw_job_shop(ax: Axes) -> None: - """Draw the Job Shop diagram.""" - mach_r = 0.4 - ax.set_xlim(0, 6) - ax.set_ylim(0, 6) - ax.set_aspect("equal") - ax.axis("off") - ax.set_title("Job Shop (Jm)", fontsize=10, fontweight="bold", pad=8) - - # Machines scattered - m_positions = [(1.5, 4.2), (4.5, 4.2), (3, 2.5)] - - for i, (mx, my) in enumerate(m_positions): - circle = plt.Circle((mx, my), mach_r, facecolor=GRAY2, edgecolor=LN, lw=1.5) - ax.add_patch(circle) - ax.text( - mx, my, f"M{i + 1}", ha="center", va="center", fontsize=9, fontweight="bold" - ) - - # J1: M1 → M2 → M3 (solid) - route1 = [(1.5, 4.2), (4.5, 4.2), (3, 2.5)] - for i in range(len(route1) - 1): - x1, y1 = route1[i] - x2, y2 = route1[i + 1] - dx = x2 - x1 - dy = y2 - y1 - d = (dx**2 + dy**2) ** 0.5 - draw_arrow( - ax, - x1 + mach_r * dx / d + 0.05, - y1 + mach_r * dy / d, - x2 - mach_r * dx / d - 0.05, - y2 - mach_r * dy / d, - lw=1.5, - ) - ax.text( - 0.3, - 4.8, - "J1: M1→M2→M3", - fontsize=7, - fontweight="bold", - bbox={"boxstyle": "round,pad=0.1", "facecolor": GRAY1, "edgecolor": LN}, - ) - - # J2: M2 → M3 → M1 (dashed) - route2 = [(4.5, 4.2), (3, 2.5), (1.5, 4.2)] - for i in range(len(route2) - 1): - x1, y1 = route2[i] - x2, y2 = route2[i + 1] - dx = x2 - x1 - dy = y2 - y1 - d = (dx**2 + dy**2) ** 0.5 - off = 0.15 # offset to avoid overlap - ax.annotate( - "", - xy=(x2 - mach_r * dx / d - 0.05, y2 - mach_r * dy / d + off), - xytext=(x1 + mach_r * dx / d + 0.05, y1 + mach_r * dy / d + off), - arrowprops={ - "arrowstyle": "->", - "color": "#555555", - "lw": 1.5, - "linestyle": "dashed", - }, - ) - ax.text( - 3.8, - 5.2, - "J2: M2→M3→M1", - fontsize=7, - fontweight="bold", - bbox={"boxstyle": "round,pad=0.1", "facecolor": GRAY4, "edgecolor": LN}, - ) - - ax.text( - 3, - 1.2, - "Każde zadanie:\nwłasna trasa!", - ha="center", - va="center", - fontsize=8, - bbox={"boxstyle": "round,pad=0.3", "facecolor": GRAY4, "edgecolor": GRAY3}, - ) - - ax.text( - 3, - 0.4, - "NP-trudny już dla 3 maszyn", - ha="center", - fontsize=7, - style="italic", - color="#666666", - ) - - -# ============================================================ -# 5. SCHEDULING COMPLEXITY LANDSCAPE -# ============================================================ -def draw_complexity_map() -> None: - """Draw complexity map.""" - _fig, ax = plt.subplots(1, 1, figsize=(8.27, 5)) - ax.set_xlim(0, 10) - ax.set_ylim(0, 7) - ax.set_aspect("equal") - ax.axis("off") - ax.set_title( - "Złożoność problemów szeregowania — od łatwych do NP-trudnych", - fontsize=FS_TITLE, - fontweight="bold", - pad=10, - ) - - # Gradient arrow at the top - ax.annotate( - "", - xy=(9.5, 6.2), - xytext=(0.5, 6.2), - arrowprops={"arrowstyle": "->", "color": LN, "lw": 2}, - ) - ax.text(5, 6.5, "Rosnąca złożoność", ha="center", fontsize=9, fontweight="bold") - - # Easy (polynomial) region - easy_rect = FancyBboxPatch( - (0.3, 2.8), - 4.0, - 3.0, - boxstyle="round,pad=0.15", - lw=1.5, - edgecolor="#666666", - facecolor=GRAY4, - linestyle="-", - ) - ax.add_patch(easy_rect) - ax.text( - 2.3, - 5.5, - "WIELOMIANOWE O(n log n)", - ha="center", - fontsize=9, - fontweight="bold", - color="#444444", - ) - - easy_problems = [ - ("1 || ΣCⱼ", "SPT", GRAY1, 4.8), - ("1 || Lmax", "EDD", GRAY2, 4.0), - ("F2 || Cmax", "Johnson", GRAY1, 3.2), - ] - for prob, method, fill, y in easy_problems: - rect = FancyBboxPatch( - (0.6, y), - 3.5, - 0.6, - boxstyle="round,pad=0.05", - lw=1, - edgecolor=LN, - facecolor=fill, - ) - ax.add_patch(rect) - ax.text( - 1.2, - y + 0.3, - prob, - ha="center", - va="center", - fontsize=8, - fontweight="bold", - fontfamily="monospace", - ) - ax.text(3.0, y + 0.3, f"→ {method}", ha="center", va="center", fontsize=8) - - # Hard (NP) region - hard_rect = FancyBboxPatch( - (5.3, 2.8), - 4.3, - 3.0, - boxstyle="round,pad=0.15", - lw=1.5, - edgecolor="#444444", - facecolor=GRAY3, - linestyle="-", - ) - ax.add_patch(hard_rect) - ax.text( - 7.45, - 5.5, - "NP-TRUDNE", - ha="center", - fontsize=9, - fontweight="bold", - color="#333333", - ) - - hard_problems = [ - ("Pm || Cmax\n(m≥2)", "LPT heuryst.", GRAY2, 4.5), - ("1 || ΣTⱼ", "branch&bound", GRAY4, 3.7), - ("Jm || Cmax\n(m≥3)", "metaheuryst.", GRAY5, 2.9), - ] - for prob, method, fill, y in hard_problems: - rect = FancyBboxPatch( - (5.6, y), - 3.7, - 0.7, - boxstyle="round,pad=0.05", - lw=1, - edgecolor=LN, - facecolor=fill, - ) - ax.add_patch(rect) - ax.text( - 6.5, - y + 0.35, - prob, - ha="center", - va="center", - fontsize=7, - fontweight="bold", - fontfamily="monospace", - ) - ax.text(8.2, y + 0.35, f"→ {method}", ha="center", va="center", fontsize=7) - - # Arrow connecting - draw_arrow(ax, 4.4, 4.0, 5.2, 4.0, lw=2, color="#888888") - ax.text(4.8, 4.25, "+1\nmaszyna", ha="center", fontsize=6, color="#888888") - - # Bottom: key insight - ax.text( - 5.0, - 1.8, - "„Dodanie jednej maszyny lub jednego ograniczenia\n" - 'może zmienić problem z łatwego na NP-trudny!"', - ha="center", - fontsize=8, - fontweight="bold", - style="italic", - bbox={ - "boxstyle": "round,pad=0.3", - "facecolor": GRAY4, - "edgecolor": GRAY3, - "lw": 1, - }, - ) - - # Bottom examples - ax.text( - 5.0, - 0.8, - "1 maszyna → łatwe (sortuj) | ≥2 maszyny równoległe → NP-trudne\n" - "Flow shop 2 maszyny → Johnson O(n log n) | Flow shop 3 maszyny → NP-trudne", - ha="center", - fontsize=7, - color="#555555", - ) - - plt.tight_layout() - plt.savefig( - str(Path(OUTPUT_DIR) / "scheduling_complexity_map.png"), - dpi=DPI, - bbox_inches="tight", - facecolor=BG, - ) - plt.close() - _logger.info(" ✓ scheduling_complexity_map.png") - - -# ============================================================ -# 6. EDD EXAMPLE (1 || Lmax) -# ============================================================ -def draw_edd_example() -> None: - """Draw edd example.""" - _fig, ax = plt.subplots(1, 1, figsize=(8.27, 4)) - ax.set_xlim(-2, 28) - ax.set_ylim(-2, 4) - ax.axis("off") - ax.set_title( - "EDD (Earliest Due Date) — 1 || Lmax — Przykład", - fontsize=FS_TITLE, - fontweight="bold", - pad=8, - ) - - # Tasks: name, processing time, due date - tasks = [("J1", 4, 10), ("J2", 2, 6), ("J3", 6, 15), ("J4", 3, 8), ("J5", 5, 18)] - # EDD: sort by due date - edd_order = sorted(tasks, key=lambda x: x[2]) - - bar_y = 1.5 - bar_h = 0.8 - t = 0 - fills_edd = [GRAY1, GRAY2, GRAY4, GRAY3, GRAY5] - - lateness_vals = [] - for i, (name, p, d) in enumerate(edd_order): - rect = mpatches.Rectangle( - (t, bar_y), p, bar_h, lw=1.2, edgecolor=LN, facecolor=fills_edd[i] - ) - ax.add_patch(rect) - ax.text( - t + p / 2, - bar_y + bar_h / 2, - f"{name}\np={p}, d={d}", - ha="center", - va="center", - fontsize=6.5, - fontweight="bold", - ) - t += p - lateness = t - d - lateness_vals.append(lateness) - - # Due date marker - ax.plot( - [d, d], [bar_y - 0.4, bar_y - 0.1], color="#888888", lw=0.8, linestyle="--" - ) - ax.text( - d, - bar_y - 0.5, - f"d={d}", - ha="center", - va="top", - fontsize=5.5, - color="#888888", - ) - - # Completion + lateness - ax.plot([t, t], [bar_y + bar_h, bar_y + bar_h + 0.15], color=LN, lw=0.8) - ax.text( - t, - bar_y + bar_h + 0.2, - f"C={t}\nL={lateness}", - ha="center", - va="bottom", - fontsize=5.5, - ) - - # Time axis - ax.plot([0, 22], [bar_y - 0.05, bar_y - 0.05], color=LN, lw=0.5) - - lmax = max(lateness_vals) - ax.text( - 22, - bar_y + bar_h / 2, - f"Lmax = {lmax}", - ha="left", - va="center", - fontsize=10, - fontweight="bold", - bbox={"boxstyle": "round,pad=0.2", "facecolor": GRAY1, "edgecolor": LN}, - ) - - # Bottom mnemonic - ax.text( - 10, - -1.3, - '„Early Due Date Does it first" — najpilniejszy deadline idzie pierwszy', - ha="center", - fontsize=8, - fontweight="bold", - style="italic", - bbox={ - "boxstyle": "round,pad=0.3", - "facecolor": GRAY4, - "edgecolor": GRAY3, - "lw": 0.8, - }, - ) - - plt.tight_layout() - plt.savefig( - str(Path(OUTPUT_DIR) / "scheduling_edd_example.png"), - dpi=DPI, - bbox_inches="tight", - facecolor=BG, - ) - plt.close() - _logger.info(" ✓ scheduling_edd_example.png") - - # ============================================================ # MAIN # ============================================================ diff --git a/python_pkg/praca_magisterska_video/visualize_q23.py b/python_pkg/praca_magisterska_video/visualize_q23.py index 91894e8..20cd7e7 100644 --- a/python_pkg/praca_magisterska_video/visualize_q23.py +++ b/python_pkg/praca_magisterska_video/visualize_q23.py @@ -1,1548 +1,33 @@ """MoviePy visualization for PYTANIE 23: Image Segmentation. -Creates animated video demonstrating: -- What segmentation is (pixel-level classification) -- Thresholding / Otsu (bimodal histogram) -- Region Growing (BFS flood fill) -- Watershed (topographic flooding) -- U-Net encoder-decoder architecture +Thin orchestrator that assembles sections from submodules into +the final video. """ from __future__ import annotations -import logging -import os -from pathlib import Path +from moviepy import VideoClip, concatenate_videoclips -import numpy as np - -os.environ["FFMPEG_BINARY"] = "/usr/bin/ffmpeg" - -from moviepy import ( - ColorClip, - CompositeVideoClip, - TextClip, - VideoClip, - concatenate_videoclips, +from python_pkg.praca_magisterska_video._q23_classical import ( + _region_growing_demo, + _segmentation_concept, + _thresholding_demo, + _watershed_demo, ) -from moviepy.video.fx import FadeIn, FadeOut +from python_pkg.praca_magisterska_video._q23_deeplab import _deeplab_demo +from python_pkg.praca_magisterska_video._q23_helpers import ( + FPS, + OUTPUT, + _logger, + _make_header, +) +from python_pkg.praca_magisterska_video._q23_transformer import ( + _methods_comparison, + _transformer_seg_demo, +) +from python_pkg.praca_magisterska_video._q23_unet_fcn import _fcn_demo, _unet_demo -# ── Constants ───────────────────────────────────────────────────── -W, H = 1280, 720 -FPS = 24 -STEP_DUR = 7.0 -HEADER_DUR = 4.0 -FONT_B = "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf" -FONT_R = "/usr/share/fonts/TTF/DejaVuSans.ttf" -OUTPUT_DIR = Path(__file__).resolve().parent / "videos" -OUTPUT_DIR.mkdir(parents=True, exist_ok=True) -OUTPUT = str(OUTPUT_DIR / "q23_segmentation.mp4") -logging.basicConfig(level=logging.INFO) -_logger = logging.getLogger(__name__) - -BG_COLOR = (15, 20, 35) -rng = np.random.default_rng(42) - - -def _tc(**kwargs: object) -> TextClip: - """TextClip wrapper that adds enough bottom margin to prevent clipping.""" - fs = kwargs.get("font_size", 24) - m = int(fs) // 3 + 2 - kwargs["margin"] = (0, m) - return TextClip(**kwargs) - - -def _make_header( - title: str, subtitle: str, duration: float = HEADER_DUR -) -> CompositeVideoClip: - bg = ColorClip(size=(W, H), color=BG_COLOR).with_duration(duration) - t = ( - _tc( - text=title, - font_size=48, - color="white", - font=FONT_B, - ) - .with_duration(duration) - .with_position(("center", 260)) - ) - s = ( - _tc( - text=subtitle, - font_size=24, - color="#90CAF9", - font=FONT_R, - ) - .with_duration(duration) - .with_position(("center", 340)) - ) - return CompositeVideoClip([bg, t, s], size=(W, H)).with_effects( - [FadeIn(0.5), FadeOut(0.5)] - ) - - -def _text_slide( - lines: list[tuple[str, int, str, str, tuple[str | int, str | int]]], - duration: float = STEP_DUR, -) -> CompositeVideoClip: - """Create a slide with multiple text elements.""" - bg = ColorClip(size=(W, H), color=BG_COLOR).with_duration(duration) - clips: list[VideoClip] = [bg] - for text, font_size, color, font, pos in lines: - tc = ( - _tc( - text=text, - font_size=font_size, - color=color, - font=font, - ) - .with_duration(duration) - .with_position(pos) - ) - clips.append(tc) - return CompositeVideoClip(clips, size=(W, H)).with_effects( - [FadeIn(0.3), FadeOut(0.3)] - ) - - -def _compose_slide( - base_clip: VideoClip, - labels: list[tuple[str, int, str, str, tuple[int, int]]], - duration: float, -) -> CompositeVideoClip: - """Overlay text labels on an animated base clip.""" - text_clips: list[VideoClip] = [base_clip] - for text, fs, color, font, pos in labels: - tc = ( - _tc(text=text, font_size=fs, color=color, font=font) - .with_duration(duration) - .with_position(pos) - ) - text_clips.append(tc) - return CompositeVideoClip(text_clips, size=(W, H)).with_effects( - [FadeIn(0.3), FadeOut(0.3)] - ) - - -# ── Segmentation concept ───────────────────────────────────────── -def _segmentation_concept() -> list[CompositeVideoClip]: - """Show what segmentation is: pixel-level labeling.""" - slides = [] - - # Synthetic image: grid of colored pixels - def make_image_frame(_t: float) -> np.ndarray: - frame = np.zeros((H, W, 3), dtype=np.uint8) - frame[:] = BG_COLOR - - # Draw a small "image" grid - grid_x, grid_y = 100, 150 - cell = 40 - # Sky (top rows) - colors_map = [ - [(135, 206, 235)] * 8, # sky - [(135, 206, 235)] * 5 + [(34, 139, 34)] * 3, # sky + tree - [(34, 139, 34)] * 3 - + [(128, 128, 128)] * 3 - + [(34, 139, 34)] * 2, # tree+road+tree - [(128, 128, 128)] * 3 - + [(200, 50, 50)] * 2 - + [(128, 128, 128)] * 3, # road+car+road - ] - labels_map = [ - ["niebo"] * 8, - ["niebo"] * 5 + ["drzewo"] * 3, - ["drzewo"] * 3 + ["droga"] * 3 + ["drzewo"] * 2, - ["droga"] * 3 + ["samochód"] * 2 + ["droga"] * 3, - ] - label_colors = { - "niebo": (100, 180, 255), - "drzewo": (50, 200, 50), - "droga": (180, 180, 180), - "samochód": (255, 80, 80), - } - - for r, row in enumerate(colors_map): - for c, col in enumerate(row): - y = grid_y + r * cell - x = grid_x + c * cell - frame[y : y + cell - 2, x : x + cell - 2] = col - - # Draw segmentation map on the right - seg_x = 600 - for r, row in enumerate(labels_map): - for c, lab in enumerate(row): - y = grid_y + r * cell - x = seg_x + c * cell - frame[y : y + cell - 2, x : x + cell - 2] = label_colors[lab] - - return frame - - image_clip = VideoClip(make_image_frame, duration=STEP_DUR).with_fps(FPS) - labels_text = [ - ("Obraz wejściowy", 22, "white", FONT_B, (170, 100)), - ("Mapa segmentacji", 22, "white", FONT_B, (660, 100)), - ("→", 50, "#FFE082", FONT_B, (450, 250)), - ("Każdy piksel → etykieta klasy", 20, "#B0BEC5", FONT_R, (100, 420)), - ("niebo | drzewo | droga | samochód", 18, "#90CAF9", FONT_R, (600, 420)), - ("Segmentacja = klasyfikacja per-piksel", 24, "#FFE082", FONT_B, (100, 500)), - ( - "Semantic: klasy bez instancji | Instance: " - "rozróżnia obiekty | Panoptic: oba", - 16, - "#78909C", - FONT_R, - (100, 560), - ), - ] - clips: list[VideoClip] = [image_clip] - for text, fs, color, font, pos in labels_text: - tc = ( - _tc(text=text, font_size=fs, color=color, font=font) - .with_duration(STEP_DUR) - .with_position(pos) - ) - clips.append(tc) - - slides.append( - CompositeVideoClip(clips, size=(W, H)).with_effects([FadeIn(0.3), FadeOut(0.3)]) - ) - return slides - - -# ── Thresholding / Otsu ─────────────────────────────────────────── -def _thresholding_demo() -> list[CompositeVideoClip]: - """Animate thresholding and Otsu concept.""" - slides = [] - - # Show histogram & threshold - def make_threshold_frame(t: float) -> np.ndarray: - frame = np.zeros((H, W, 3), dtype=np.uint8) - frame[:] = BG_COLOR - - # Draw bimodal histogram bars - bar_start_x = 80 - bar_y = 500 - bar_w = 4 - - for i in range(256): - # Bimodal: peaks at 60 and 190 - h1 = 200 * np.exp(-((i - 60) ** 2) / (2 * 20**2)) - h2 = 150 * np.exp(-((i - 190) ** 2) / (2 * 25**2)) - bar_h = int(h1 + h2) - x = bar_start_x + i * bar_w - if x + bar_w < W: - frame[bar_y - bar_h : bar_y, x : x + bar_w - 1] = (150, 150, 170) - - # Animated threshold line - threshold = int(60 + (190 - 60) * min(t / (STEP_DUR * 0.7), 1.0)) - tx = bar_start_x + threshold * bar_w - if tx < W: - frame[bar_y - 250 : bar_y + 10, tx : tx + 3] = (255, 80, 80) - - # Color the two sides - for i in range(threshold): - x = bar_start_x + i * bar_w - h1 = 200 * np.exp(-((i - 60) ** 2) / (2 * 20**2)) - h2 = 150 * np.exp(-((i - 190) ** 2) / (2 * 25**2)) - bar_h = int(h1 + h2) - if x + bar_w < W and bar_h > 0: - frame[bar_y - bar_h : bar_y, x : x + bar_w - 1] = (70, 130, 200) - - for i in range(threshold, 256): - x = bar_start_x + i * bar_w - h1 = 200 * np.exp(-((i - 60) ** 2) / (2 * 20**2)) - h2 = 150 * np.exp(-((i - 190) ** 2) / (2 * 25**2)) - bar_h = int(h1 + h2) - if x + bar_w < W and bar_h > 0: - frame[bar_y - bar_h : bar_y, x : x + bar_w - 1] = (200, 100, 80) - - return frame - - hist_clip = VideoClip(make_threshold_frame, duration=STEP_DUR).with_fps(FPS) - text_clips: list[VideoClip] = [hist_clip] - labels = [ - ("Progowanie (Thresholding) z metodą Otsu", 28, "#FFE082", FONT_B, (80, 30)), - ( - "Histogram jasności pikseli — dwumodalny (bimodal)", - 20, - "#B0BEC5", - FONT_R, - (80, 80), - ), - ("Garb 1: piksele obiektu (ciemne ~60)", 16, "#64B5F6", FONT_R, (80, 120)), - ("Garb 2: piksele tła (jasne ~190)", 16, "#EF9A9A", FONT_R, (80, 150)), - ( - "Próg T (czerwona linia) dzieli piksele na 2 klasy", - 18, - "white", - FONT_R, - (80, 540), - ), - ( - "Otsu: automatycznie testuje T=0..255, minimalizuje σ² wewnątrzklasową", - 16, - "#A5D6A7", - FONT_R, - (80, 580), - ), - ( - "Piksel ≤ T → klasa 0 (tło) | Piksel > T → klasa 1 (obiekt)", - 16, - "#78909C", - FONT_R, - (80, 620), - ), - ] - for text, fs, color, font, pos in labels: - tc = ( - _tc(text=text, font_size=fs, color=color, font=font) - .with_duration(STEP_DUR) - .with_position(pos) - ) - text_clips.append(tc) - - slides.append( - CompositeVideoClip(text_clips, size=(W, H)).with_effects( - [FadeIn(0.3), FadeOut(0.3)] - ) - ) - return slides - - -# ── Region Growing ──────────────────────────────────────────────── -def _region_growing_demo() -> list[CompositeVideoClip]: - """Animate region growing BFS from a seed pixel.""" - slides = [] - - grid_size = 10 - cell_size = 40 - rng = np.random.default_rng(42) - # Create a simple grid: dark region (30-80) and bright region (160-220) - grid = np.zeros((grid_size, grid_size), dtype=np.uint8) - grid[:] = 60 # dark background - grid[2:7, 3:8] = 180 # bright rectangle - - # Add some noise - noise = rng.integers(-15, 15, (grid_size, grid_size)) - grid = np.clip(grid.astype(int) + noise, 0, 255).astype(np.uint8) - - # BFS steps from seed (4, 5) - seed = (4, 5) - threshold_val = 50 - visited_order: list[tuple[int, int]] = [] - queue = [seed] - visited_set = {seed} - while queue: - r, c = queue.pop(0) - visited_order.append((r, c)) - for dr, dc in [(-1, 0), (1, 0), (0, -1), (0, 1)]: - nr, nc = r + dr, c + dc - if ( - 0 <= nr < grid_size - and 0 <= nc < grid_size - and (nr, nc) not in visited_set - ) and abs(int(grid[nr, nc]) - int(grid[seed])) < threshold_val: - visited_set.add((nr, nc)) - queue.append((nr, nc)) - - def make_region_frame(t: float) -> np.ndarray: - frame = np.zeros((H, W, 3), dtype=np.uint8) - frame[:] = BG_COLOR - ox, oy = 100, 180 - - # How many cells to show as visited - progress = min(t / (STEP_DUR * 0.8), 1.0) - n_visited = int(progress * len(visited_order)) - - for r in range(grid_size): - for c in range(grid_size): - x = ox + c * cell_size - y = oy + r * cell_size - val = grid[r, c] - color = (val, val, val) - - # Highlight visited - if (r, c) in visited_order[:n_visited]: - color = (80, 200, 120) # green for region - elif (r, c) == seed: - color = (255, 200, 50) # yellow seed - - frame[y : y + cell_size - 2, x : x + cell_size - 2] = color - - # Show value - # (drawn as a simple marker since we can't render text in numpy easily) - - # Mark the seed with a bright border - sx = ox + seed[1] * cell_size - sy = ox + seed[0] * cell_size + 80 - frame[sy : sy + cell_size, sx : sx + 2] = (255, 200, 50) - frame[sy : sy + cell_size, sx + cell_size - 2 : sx + cell_size] = (255, 200, 50) - frame[sy : sy + 2, sx : sx + cell_size] = (255, 200, 50) - frame[sy + cell_size - 2 : sy + cell_size, sx : sx + cell_size] = (255, 200, 50) - - return frame - - region_clip = VideoClip(make_region_frame, duration=STEP_DUR).with_fps(FPS) - text_clips: list[VideoClip] = [region_clip] - labels = [ - ("Region Growing — rozrastanie regionu", 28, "#FFE082", FONT_B, (100, 30)), - ("Seed (ziarno) → BFS do podobnych sąsiadów", 20, "#B0BEC5", FONT_R, (100, 80)), - ( - "Żółty = seed | Zielony = region | Szary = nieodwiedzone", - 16, - "#78909C", - FONT_R, - (100, 120), - ), - ( - "Sąsiad PODOBNY (|jasność - jasność_regionu| < próg) → dodaj do regionu", - 16, - "#A5D6A7", - FONT_R, - (100, 600), - ), - ( - "Algorytm zatrzymuje się gdy brak podobnych sąsiadów", - 16, - "#90CAF9", - FONT_R, - (100, 640), - ), - ( - "Mnemonik: PLAMA atramentu — rozlewa się na podobne piksele", - 18, - "#EF9A9A", - FONT_R, - (100, 670), - ), - ] - for text, fs, color, font, pos in labels: - tc = ( - _tc(text=text, font_size=fs, color=color, font=font) - .with_duration(STEP_DUR) - .with_position(pos) - ) - text_clips.append(tc) - - slides.append( - CompositeVideoClip(text_clips, size=(W, H)).with_effects( - [FadeIn(0.3), FadeOut(0.3)] - ) - ) - return slides - - -# ── Watershed ───────────────────────────────────────────────────── -def _watershed_demo() -> list[CompositeVideoClip]: - """Animate watershed flooding concept.""" - slides = [] - - def make_watershed_frame(t: float) -> np.ndarray: - frame = np.zeros((H, W, 3), dtype=np.uint8) - frame[:] = BG_COLOR - - # Draw terrain profile (1D cross-section) - ox, oy = 100, 450 - terrain_w = 900 - terrain_points = 100 - - xs = np.linspace(0, 1, terrain_points) - # Two valleys with a ridge - terrain = ( - 120 * np.exp(-((xs - 0.25) ** 2) / 0.005) - + 80 * np.exp(-((xs - 0.75) ** 2) / 0.008) - + 30 - ) - terrain = 250 - terrain # invert for visual (valleys at bottom) - - # Water level rises over time - water_level = int(160 + 80 * min(t / (STEP_DUR * 0.7), 1.0)) - - for i in range(terrain_points - 1): - x1 = ox + int(xs[i] * terrain_w) - x2 = ox + int(xs[i + 1] * terrain_w) - y1 = oy - int(terrain[i]) - y2 = oy - int(terrain[i + 1]) - - # Fill terrain - for x in range(x1, min(x2 + 1, W)): - top = min(y1, y2) - 5 - frame[top:oy, x : x + 1] = (100, 80, 60) - - # Fill water - water_y = oy - water_level - for x in range(x1, min(x2 + 1, W)): - t_y = oy - int(terrain[i]) - if water_y < t_y: - # Water fills below terrain surface - fill_top = max(water_y, 0) - fill_bot = min(t_y, oy) - if fill_top < fill_bot: - frame[fill_top:fill_bot, x : x + 1] = (70, 130, 220) - - # Dam marker at ridge - ridge_x = ox + int(0.5 * terrain_w) - dam_visible_threshold = 160 - if water_level > dam_visible_threshold: - frame[oy - water_level : oy - 140, ridge_x - 2 : ridge_x + 2] = ( - 255, - 80, - 80, - ) - - return frame - - ws_clip = VideoClip(make_watershed_frame, duration=STEP_DUR).with_fps(FPS) - text_clips: list[VideoClip] = [ws_clip] - labels = [ - ("Watershed — metoda zlewiska", 28, "#FFE082", FONT_B, (100, 20)), - ( - "Obraz = mapa topograficzna (jasność = wysokość)", - 20, - "#B0BEC5", - FONT_R, - (100, 65), - ), - ( - "Brązowy = teren (ciemne=doliny, jasne=szczyty)", - 16, - "#8D6E63", - FONT_R, - (100, 100), - ), - ("Niebieski = woda zalewająca od minimów", 16, "#64B5F6", FONT_R, (100, 130)), - ( - "Czerwony = TAMA (granica segmentu) — gdy woda z 2 dolin się spotka", - 16, - "#EF9A9A", - FONT_R, - (100, 160), - ), - ( - "Problem: over-segmentation " - "(za dużo regionów). " - "Rozwiązanie: marker-controlled.", - 16, - "#A5D6A7", - FONT_R, - (100, 560), - ), - ( - "Mnemonik: ZALEWANIE terenu — granie gór = granice segmentów", - 18, - "#FFE082", - FONT_R, - (100, 600), - ), - ] - for text, fs, color, font, pos in labels: - tc = ( - _tc(text=text, font_size=fs, color=color, font=font) - .with_duration(STEP_DUR) - .with_position(pos) - ) - text_clips.append(tc) - - slides.append( - CompositeVideoClip(text_clips, size=(W, H)).with_effects( - [FadeIn(0.3), FadeOut(0.3)] - ) - ) - return slides - - -# ── U-Net Architecture ─────────────────────────────────────────── -def _draw_unet_skips( - frame: np.ndarray, - enc_positions: list[tuple[int, int, int, int]], - n_blocks: int, - dec_x: int, - skip_threshold: int, -) -> None: - """Draw horizontal dashed skip-connection lines.""" - if n_blocks <= skip_threshold: - return - for i in range(min(n_blocks - 5, 4)): - ey = enc_positions[i][1] + enc_positions[i][3] // 2 - ex_end = enc_positions[i][0] + enc_positions[i][2] - for dash_x in range(ex_end + 10, dec_x - 10, 15): - frame[ey : ey + 2, dash_x : dash_x + 8] = (255, 200, 50) - - -def _make_unet_frame(t: float) -> np.ndarray: - """Render a single U-Net animation frame.""" - frame = np.zeros((H, W, 3), dtype=np.uint8) - frame[:] = BG_COLOR - - enc_sizes = [(80, 120), (60, 100), (45, 80), (30, 60)] - dec_sizes = list(reversed(enc_sizes)) - enc_x = 150 - dec_x = 850 - - progress = min(t / (STEP_DUR * 0.6), 1.0) - n_blocks = int(progress * 8) + 1 - - enc_positions: list[tuple[int, int, int, int]] = [] - y_offset = 120 - for i, (bw, bh) in enumerate(enc_sizes): - x = enc_x - y = y_offset + i * 130 - enc_positions.append((x, y, bw, bh)) - if i < n_blocks: - frame[y : y + bh, x : x + bw] = (70, 130, 200) - frame[y : y + 2, x : x + bw] = (100, 180, 255) - frame[y + bh - 2 : y + bh, x : x + bw] = (100, 180, 255) - frame[y : y + bh, x : x + 2] = (100, 180, 255) - frame[y : y + bh, x + bw - 2 : x + bw] = (100, 180, 255) - if i < len(enc_sizes) - 1: - ax = x + bw // 2 - ay = y + bh + 10 - frame[ay : ay + 20, ax - 1 : ax + 2] = (150, 150, 170) - - bx, by = 500, y_offset + 3 * 130 + 30 - encoder_count = 4 - if n_blocks > encoder_count: - frame[by : by + 50, bx : bx + 25] = (200, 100, 80) - frame[by : by + 2, bx : bx + 25] = (255, 140, 100) - frame[by + 48 : by + 50, bx : bx + 25] = (255, 140, 100) - - for i, (bw, bh) in enumerate(dec_sizes): - x = dec_x - y = y_offset + (3 - i) * 130 - if n_blocks > 4 + i + 1: - frame[y : y + bh, x : x + bw] = (80, 200, 120) - frame[y : y + 2, x : x + bw] = (120, 230, 150) - frame[y + bh - 2 : y + bh, x : x + bw] = (120, 230, 150) - frame[y : y + bh, x : x + 2] = (120, 230, 150) - frame[y : y + bh, x + bw - 2 : x + bw] = (120, 230, 150) - if i < len(dec_sizes) - 1: - ax = x + bw // 2 - ay = y - 30 - frame[ay : ay + 20, ax - 1 : ax + 2] = (150, 150, 170) - - skip_threshold = 5 - _draw_unet_skips(frame, enc_positions, n_blocks, dec_x, skip_threshold) - - return frame - - -def _unet_demo() -> list[CompositeVideoClip]: - """Animate U-Net encoder-decoder architecture.""" - dur = STEP_DUR + 1 - unet_clip = VideoClip(_make_unet_frame, duration=dur).with_fps(FPS) - labels = [ - ("U-Net: Encoder-Decoder + Skip Connections", 28, "#FFE082", FONT_B, (80, 20)), - ( - "Niebieski = Encoder (↓ zmniejsza rozdzielczość, wyciąga cechy)", - 16, - "#64B5F6", - FONT_R, - (80, 65), - ), - ( - "Zielony = Decoder (↑ zwiększa rozdzielczość, odtwarza mapę)", - 16, - "#A5D6A7", - FONT_R, - (80, 90), - ), - ( - "Żółte przerywane = Skip connections (przenoszą detale z encodera)", - 16, - "#FFE082", - FONT_R, - (80, 115), - ), - ( - "Czerwony = Bottleneck (najgłębsza warstwa, max abstrakcja)", - 16, - "#EF9A9A", - FONT_R, - (450, 570), - ), - ( - "Kształt U: encoder ↓ decoder ↑, mosty pośrodku", - 18, - "white", - FONT_R, - (80, 640), - ), - ( - "Concatenation: skip łączy kanały (więcej informacji niż dodawanie)", - 16, - "#78909C", - FONT_R, - (80, 670), - ), - ] - return [_compose_slide(unet_clip, labels, dur)] - - -# ── FCN Architecture ───────────────────────────────────────────── -def _draw_pipeline_blocks( - frame: np.ndarray, - blocks: list[ - tuple[tuple[int, int], tuple[int, int], tuple[int, int, int]] - ], - n_visible: int, - arrow_limit: int, -) -> None: - """Draw coloured blocks with connecting arrows.""" - for i, ((bx, by), (bw, bh), color) in enumerate(blocks): - if i < n_visible: - frame[by : by + bh, bx : bx + bw] = color - frame[by : by + 2, bx : bx + bw] = tuple( - min(c + 50, 255) for c in color - ) - frame[by + bh - 2 : by + bh, bx : bx + bw] = tuple( - min(c + 50, 255) for c in color - ) - if i < arrow_limit: - ax = bx + bw + 3 - ay = by + bh // 2 - frame[ay - 1 : ay + 2, ax : ax + 12] = (150, 150, 170) - - -def _draw_red_cross( - frame: np.ndarray, - x_start: int, - width: int, - top_y: int, - height: int, -) -> None: - """Draw a red X across the given rectangle.""" - for d in range(-2, 3): - for step in range(height): - x1 = x_start + int(step * width / height) - y1 = top_y + step + d - if 0 <= y1 < H and 0 <= x1 < W: - frame[y1, x1] = (255, 80, 80) - y2 = top_y + height - step + d - if 0 <= y2 < H and 0 <= x1 < W: - frame[y2, x1] = (255, 80, 80) - - -def _make_fcn_frame(t: float) -> np.ndarray: - """Render a single FCN comparison frame.""" - frame = np.zeros((H, W, 3), dtype=np.uint8) - frame[:] = BG_COLOR - progress = min(t / (STEP_DUR * 0.8), 1.0) - - top_y = 140 - blocks_classic = [ - ((80, top_y), (70, 50), (70, 130, 200)), - ((170, top_y), (50, 40), (50, 100, 160)), - ((240, top_y), (60, 50), (70, 130, 200)), - ((320, top_y), (40, 35), (50, 100, 160)), - ((385, top_y), (55, 50), (160, 80, 60)), - ((465, top_y), (55, 50), (180, 60, 60)), - ((545, top_y), (80, 50), (200, 80, 80)), - ] - n_top = min(int(progress * 7) + 1, 7) - arrow_limit = 6 - _draw_pipeline_blocks(frame, blocks_classic, n_top, arrow_limit) - - cross_phase = 0.6 - if progress > cross_phase: - _draw_red_cross(frame, 385, 135, top_y, 50) - - bot_y = 380 - blocks_fcn = [ - ((80, bot_y), (70, 50), (70, 130, 200)), - ((170, bot_y), (50, 40), (50, 100, 160)), - ((240, bot_y), (60, 50), (70, 130, 200)), - ((320, bot_y), (40, 35), (50, 100, 160)), - ((385, bot_y), (70, 50), (80, 200, 120)), - ((480, bot_y), (75, 50), (200, 160, 80)), - ((580, bot_y), (80, 50), (100, 200, 100)), - ] - fcn_phase = 0.4 - if progress > fcn_phase: - n_bot = min(int((progress - fcn_phase) / 0.6 * 7) + 1, 7) - _draw_pipeline_blocks(frame, blocks_fcn, n_bot, arrow_limit) - - return frame - - -def _fcn_demo() -> list[CompositeVideoClip]: - """Animate FCN step-by-step: FC → Conv 1x1 transformation.""" - dur = STEP_DUR + 1 - fcn_clip = VideoClip(_make_fcn_frame, duration=dur).with_fps(FPS) - labels = [ - ("FCN: Fully Convolutional Network (2015)", 26, "#FFE082", FONT_B, (80, 20)), - ("KROK 1: Zamień FC → Conv 1x1", 18, "#A5D6A7", FONT_R, (80, 60)), - ("Klasyczny CNN:", 16, "#EF9A9A", FONT_B, (80, 105)), - ("Conv", 11, "white", FONT_R, (92, 148)), - ("Pool", 11, "white", FONT_R, (178, 148)), - ("Conv", 11, "white", FONT_R, (250, 148)), - ("Pool", 11, "white", FONT_R, (325, 148)), - ("Flatten", 11, "#EF9A9A", FONT_R, (390, 148)), - ("FC", 11, "#EF9A9A", FONT_R, (480, 148)), - ("1 label", 11, "#EF9A9A", FONT_R, (555, 148)), - ("FCN:", 16, "#A5D6A7", FONT_B, (80, 350)), - ("Conv", 11, "white", FONT_R, (92, 388)), - ("Pool", 11, "white", FONT_R, (178, 388)), - ("Conv", 11, "white", FONT_R, (250, 388)), - ("Pool", 11, "white", FONT_R, (325, 388)), - ("Conv1x1", 11, "#A5D6A7", FONT_R, (390, 388)), - ("Upsample", 11, "#FFE082", FONT_R, (486, 388)), - ("Mapa", 11, "#A5D6A7", FONT_R, (595, 388)), - ( - "FC: spłaszcza 3D→1D, wymusza stały rozmiar → 1 etykieta", - 16, - "#EF9A9A", - FONT_R, - (80, 250), - ), - ( - "Conv1x1: działa per piksel x kanały → DOWOLNY rozmiar → mapa klasy", - 16, - "#A5D6A7", - FONT_R, - (80, 460), - ), - ( - "KROK 2: Skip connections — łączą wczesne detale z późną abstrakcją", - 17, - "#64B5F6", - FONT_R, - (80, 510), - ), - ( - "Wczesne warstwy = krawędzie, tekstury | Późne = koncepty obiektów", - 15, - "#78909C", - FONT_R, - (80, 545), - ), - ( - "FCN = PIERWSZA sieć end-to-end do segmentacji per-piksel!", - 18, - "white", - FONT_R, - (80, 590), - ), - ( - "Mnemonik: FC → Conv 1x1 = otwieramy bramkę dla DOWOLNEGO rozmiaru", - 16, - "#FFE082", - FONT_R, - (80, 640), - ), - ] - slides = [_compose_slide(fcn_clip, labels, dur)] - - # Slide 2: FCN skip connections step by step - skip_lines = [ - ("FCN: Skip Connections — krok po kroku", 26, "#FFE082", FONT_B, (80, 30)), - ( - "1. Encoder zmniejsza: 224→112→56→28→14 (pooling)", - 18, - "#64B5F6", - FONT_R, - (100, 100), - ), - ( - " Każdy pooling traci detale przestrzenne (dokładne krawędzie)", - 15, - "#78909C", - FONT_R, - (100, 135), - ), - ( - "2. Decoder powiększa: 14→28→56→112→224 (upsample/deconv)", - 18, - "#A5D6A7", - FONT_R, - (100, 190), - ), - ( - " Upsample ODGADUJE piksele — rozmyty wynik!", - 15, - "#78909C", - FONT_R, - (100, 225), - ), - ( - "3. Skip connections: dodaj cechy z encodera do decodera", - 18, - "#FFE082", - FONT_R, - (100, 280), - ), - ( - " Wczesne cechy = GDZIE (precyzyjne krawędzie)", - 15, - "#64B5F6", - FONT_R, - (100, 315), - ), - ( - " Późne cechy = CO (abstrakcyjne koncepty)", - 15, - "#A5D6A7", - FONT_R, - (100, 345), - ), - ( - " Skip = daje decoderowi OBA → ostry wynik!", - 15, - "#FFE082", - FONT_R, - (100, 375), - ), - ( - "Warianty: FCN-32s (brak skip, rozmyty) → FCN-16s → FCN-8s (najlepszy)", - 16, - "#B0BEC5", - FONT_R, - (80, 440), - ), - ( - "FCN-32s: upsample 32x naraz → ROZMYTE granice", - 15, - "#EF9A9A", - FONT_R, - (100, 485), - ), - ( - "FCN-16s: skip z pool4 + upsample 16x → lepiej", - 15, - "#FFE082", - FONT_R, - (100, 520), - ), - ( - "FCN-8s: skip z pool3+pool4 + upsample 8x → OSTRE granice!", - 15, - "#A5D6A7", - FONT_R, - (100, 555), - ), - ( - "Im więcej skip connections → tym więcej " - "detali z encodera → ostrzejszy wynik", - 17, - "white", - FONT_R, - (80, 620), - ), - ] - slides.append(_text_slide(skip_lines, duration=STEP_DUR + 1)) - - return slides - - -# ── DeepLab Architecture ───────────────────────────────────────── -def _make_dilated_frame(t: float) -> np.ndarray: - """Render a dilated convolution comparison frame.""" - frame = np.zeros((H, W, 3), dtype=np.uint8) - frame[:] = BG_COLOR - progress = min(t / (STEP_DUR * 0.7), 1.0) - - cell = 36 - grids = [ - ( - "rate=1", - 60, - [ - (0, 0), - (0, 1), - (0, 2), - (1, 0), - (1, 1), - (1, 2), - (2, 0), - (2, 1), - (2, 2), - ], - ), - ( - "rate=2", - 420, - [ - (0, 0), - (0, 2), - (0, 4), - (2, 0), - (2, 2), - (2, 4), - (4, 0), - (4, 2), - (4, 4), - ], - ), - ( - "rate=3", - 820, - [ - (0, 0), - (0, 3), - (0, 6), - (3, 0), - (3, 3), - (3, 6), - (6, 0), - (6, 3), - (6, 6), - ], - ), - ] - - for gi, (_label, gx, positions) in enumerate(grids): - if progress < gi * 0.3: - break - gy = 180 - grid_size = 7 - for r in range(grid_size): - for c in range(grid_size): - x = gx + c * cell - y = gy + r * cell - frame[y : y + cell - 2, x : x + cell - 2] = (35, 40, 55) - for r, c in positions: - x = gx + c * cell - y = gy + r * cell - frame[y : y + cell - 2, x : x + cell - 2] = (70, 130, 200) - frame[y : y + 2, x : x + cell - 2] = (120, 180, 255) - frame[y + cell - 4 : y + cell - 2, x : x + cell - 2] = (120, 180, 255) - - return frame - - -def _make_aspp_frame(t: float) -> np.ndarray: - """Render a single ASPP module animation frame.""" - frame = np.zeros((H, W, 3), dtype=np.uint8) - frame[:] = BG_COLOR - progress = min(t / (STEP_DUR * 0.7), 1.0) - - frame[250:330, 50:130] = (70, 130, 200) - frame[250:252, 50:130] = (120, 180, 255) - frame[328:330, 50:130] = (120, 180, 255) - - branches = [ - ("1x1 conv", 250, (200, 170), (100, 40), (80, 200, 120)), - ("rate=6", 310, (200, 250), (100, 40), (200, 160, 80)), - ("rate=12", 370, (200, 330), (100, 40), (200, 120, 60)), - ("rate=18", 430, (200, 410), (100, 40), (180, 100, 80)), - ("GAP", 490, (200, 490), (100, 40), (160, 80, 160)), - ] - n_branches = min(int(progress * 5) + 1, 5) - for i, (_lbl, _h, (bx, by), (bw, bh), color) in enumerate(branches): - if i < n_branches: - frame[by : by + bh, bx : bx + bw] = color - frame[by : by + 2, bx : bx + bw] = tuple( - min(c + 50, 255) for c in color - ) - ay = by + bh // 2 - frame[ay - 1 : ay + 2, 133:197] = (150, 150, 170) - - concat_phase = 0.6 - if progress > concat_phase: - frame[250:530, 380:420] = (50, 60, 80) - frame[250:252, 380:420] = (200, 200, 100) - frame[528:530, 380:420] = (200, 200, 100) - for i, (_lbl, _h, (bx, by), (bw, bh), _c) in enumerate(branches): - if i < n_branches: - ay = by + bh // 2 - frame[ay - 1 : ay + 2, bx + bw + 3 : 378] = (150, 150, 170) - - final_conv_phase = 0.8 - if progress > final_conv_phase: - frame[350:420, 450:550] = (100, 200, 100) - frame[350:352, 450:550] = (150, 230, 150) - frame[418:420, 450:550] = (150, 230, 150) - frame[388:391, 423:448] = (150, 150, 170) - - return frame - - -def _deeplab_demo() -> list[CompositeVideoClip]: - """Animate DeepLab: dilated convolution + ASPP step by step.""" - dur = STEP_DUR + 1 - - # Slide 1: Regular vs Dilated convolution - dil_clip = VideoClip(_make_dilated_frame, duration=dur).with_fps(FPS) - labels = [ - ("DeepLab: Atrous (Dilated) Convolution", 26, "#FFE082", FONT_B, (80, 20)), - ( - "KROK 1: Zrozum dilated convolution — filtr z DZIURAMI", - 18, - "#A5D6A7", - FONT_R, - (80, 60), - ), - ("rate=1 (zwykła)", 14, "#64B5F6", FONT_B, (60, 160)), - ("RF = 3x3", 14, "#64B5F6", FONT_R, (60, 440)), - ("9 wag, kontekst 3px", 12, "#78909C", FONT_R, (60, 470)), - ("rate=2 (dilated)", 14, "#FFE082", FONT_B, (420, 160)), - ("RF = 5x5", 14, "#FFE082", FONT_R, (420, 440)), - ("9 wag, kontekst 5px!", 12, "#78909C", FONT_R, (420, 470)), - ("rate=3 (dilated)", 14, "#A5D6A7", FONT_B, (820, 160)), - ("RF = 7x7", 14, "#A5D6A7", FONT_R, (820, 440)), - ("9 wag, kontekst 7px!", 12, "#78909C", FONT_R, (820, 470)), - ( - "Niebieski = pozycja wag filtra 3x3 | Szary = pominięte (dziury)", - 15, - "#B0BEC5", - FONT_R, - (80, 510), - ), - ( - "TE SAME 9 wag → WIĘKSZE pole widzenia " - "→ lepszy kontekst BEZ dodatkowych parametrów!", - 16, - "white", - FONT_R, - (80, 550), - ), - ( - "Mnemonik: DZIURY w filtrze — à trous = z dziurami (fr.)", - 16, - "#FFE082", - FONT_R, - (80, 600), - ), - ] - slides = [_compose_slide(dil_clip, labels, dur)] - - # Slide 2: ASPP module step by step - aspp_clip = VideoClip(_make_aspp_frame, duration=dur).with_fps(FPS) - labels2 = [ - ( - "DeepLab: ASPP (Atrous Spatial Pyramid Pooling)", - 24, - "#FFE082", - FONT_B, - (80, 20), - ), - ( - "KROK 2: Multi-scale — analizuj obraz na WIELU skalach naraz", - 17, - "#A5D6A7", - FONT_R, - (80, 60), - ), - ("Wejście", 13, "#64B5F6", FONT_B, (55, 235)), - ("Conv 1x1", 12, "white", FONT_R, (210, 178)), - ("Dilated r=6", 12, "white", FONT_R, (205, 258)), - ("Dilated r=12", 12, "white", FONT_R, (203, 338)), - ("Dilated r=18", 12, "white", FONT_R, (203, 418)), - ("GAP (global)", 12, "white", FONT_R, (205, 498)), - ("Concat", 13, "#FFE082", FONT_B, (381, 537)), - ("Conv", 13, "#A5D6A7", FONT_B, (470, 425)), - ( - "5 gałęzi RÓWNOLEGŁYCH → różne skale kontekstu:", - 16, - "#B0BEC5", - FONT_R, - (550, 170), - ), - (" 1x1: kontekst punktowy (piksel)", 14, "#A5D6A7", FONT_R, (560, 210)), - (" r=6: kontekst lokalny (~13px)", 14, "#FFE082", FONT_R, (560, 245)), - (" r=12: kontekst średni (~25px)", 14, "#FFE082", FONT_R, (560, 280)), - (" r=18: kontekst szeroki (~37px)", 14, "#FFE082", FONT_R, (560, 315)), - (" GAP: kontekst GLOBALNY (cały obraz)", 14, "#CE93D8", FONT_R, (560, 350)), - ("Concat → 1x1 conv → mapa segmentacji", 16, "#A5D6A7", FONT_R, (550, 400)), - ( - "Efekt: sieć widzi OD piksela DO całego obrazu naraz!", - 17, - "white", - FONT_R, - (80, 600), - ), - ( - "Mnemonik: ASPP = Piramida z DZIURAMI, patrzy na 5 skal jednocześnie", - 15, - "#FFE082", - FONT_R, - (80, 645), - ), - ] - slides.append(_compose_slide(aspp_clip, labels2, dur)) - - return slides - - -# ── Transformer Segmentation ──────────────────────────────────── -def _draw_base_grid( - frame: np.ndarray, gx: int, gy: int, grid_n: int, cell: int, -) -> None: - """Draw an empty grid of cells.""" - for r in range(grid_n): - for c in range(grid_n): - x = gx + c * cell - y = gy + r * cell - frame[y : y + cell - 2, x : x + cell - 2] = (35, 40, 55) - - -def _draw_cnn_kernel( - frame: np.ndarray, lx: int, ly: int, cell: int, progress: float, -) -> None: - """Highlight a 3x3 CNN kernel on the grid.""" - cnn_phase = 0.2 - if progress <= cnn_phase: - return - cx, cy = 2, 2 - for dr in range(-1, 2): - for dc in range(-1, 2): - r, c = cy + dr, cx + dc - x = lx + c * cell - y = ly + r * cell - frame[y : y + cell - 2, x : x + cell - 2] = (70, 130, 200) - x = lx + cx * cell - y = ly + cy * cell - frame[y : y + cell - 2, x : x + cell - 2] = (120, 180, 255) - - -def _draw_conn_line( - frame: np.ndarray, x0: int, y0: int, x1: int, y1: int, -) -> None: - """Draw a dashed connection line between two points.""" - steps = max(abs(x1 - x0), abs(y1 - y0)) - if steps <= 0: - return - for s in range(0, steps, 3): - px = x0 + int((x1 - x0) * s / steps) - py = y0 + int((y1 - y0) * s / steps) - if 0 <= px < W - 1 and 0 <= py < H - 1: - frame[py : py + 1, px : px + 1] = (200, 180, 50) - - -def _draw_attention_connections( - frame: np.ndarray, - origin: tuple[int, int], - grid_n: int, - cell: int, - progress: float, -) -> None: - """Draw transformer self-attention connections on the grid.""" - rx, ry = origin - transformer_phase = 0.4 - if progress <= transformer_phase: - return - cx_t, cy_t = 2, 2 - x0 = rx + cx_t * cell + cell // 2 - y0 = ry + cy_t * cell + cell // 2 - n_connections = int(progress * 36) - conn_idx = 0 - for r in range(grid_n): - for c in range(grid_n): - conn_idx += 1 - if conn_idx > n_connections: - break - x = rx + c * cell - y = ry + r * cell - dist = abs(r - cy_t) + abs(c - cx_t) - strength = max(30, 200 - dist * 30) - frame[y : y + cell - 2, x : x + cell - 2] = ( - strength // 3, - strength // 2, - strength, - ) - _draw_conn_line(frame, x0, y0, x + cell // 2, y + cell // 2) - else: - continue - break - x = rx + cx_t * cell - y = ry + cy_t * cell - frame[y : y + cell - 2, x : x + cell - 2] = (255, 200, 50) - - -def _make_attention_frame(t: float) -> np.ndarray: - """Render a CNN-vs-Transformer attention comparison frame.""" - frame = np.zeros((H, W, 3), dtype=np.uint8) - frame[:] = BG_COLOR - progress = min(t / (STEP_DUR * 0.7), 1.0) - - cell = 40 - grid_n = 6 - - lx, ly = 60, 200 - _draw_base_grid(frame, lx, ly, grid_n, cell) - _draw_cnn_kernel(frame, lx, ly, cell, progress) - - rx, ry = 680, 200 - _draw_base_grid(frame, rx, ry, grid_n, cell) - _draw_attention_connections(frame, (rx, ry), grid_n, cell, progress) - - return frame - - -def _transformer_seg_demo() -> list[CompositeVideoClip]: - """Animate transformer-based segmentation: self-attention concept.""" - dur = STEP_DUR + 1 - - # Slide 1: CNN local vs Transformer global - att_clip = VideoClip(_make_attention_frame, duration=dur).with_fps(FPS) - labels = [ - ("Transformer: Self-Attention w segmentacji", 26, "#FFE082", FONT_B, (80, 20)), - ("CNN = LOKALNY kontekst", 18, "#64B5F6", FONT_B, (60, 160)), - ("Transformer = GLOBALNY kontekst", 18, "#FFE082", FONT_B, (680, 160)), - ("Filtr 3x3 widzi", 14, "#64B5F6", FONT_R, (60, 460)), - ("TYLKO 9 sąsiadów", 14, "#64B5F6", FONT_R, (60, 485)), - ("Self-attention: każdy", 14, "#FFE082", FONT_R, (680, 460)), - ("piksel widzi WSZYSTKIE!", 14, "#FFE082", FONT_R, (680, 485)), - ("vs", 28, "#B0BEC5", FONT_B, (450, 300)), - ] - slides = [_compose_slide(att_clip, labels, dur)] - - # Slide 2: Self-attention Q/K/V step by step - qkv_lines = [ - ("Self-Attention: Q / K / V krok po kroku", 26, "#FFE082", FONT_B, (80, 30)), - ("Każdy piksel (token) tworzy 3 wektory:", 18, "#B0BEC5", FONT_R, (100, 100)), - ( - " Q (Query) = 'czego szukam?' - pytanie piksela", - 17, - "#64B5F6", - FONT_R, - (120, 145), - ), - ( - " K (Key) = 'co oferuj\u0119?' - odpowied\u017a piksela", - 17, - "#A5D6A7", - FONT_R, - (120, 185), - ), - ( - " V (Value) = 'moja warto\u015b\u0107' - informacja do przekazania", - 17, - "#FFE082", - FONT_R, - (120, 225), - ), - ("Algorytm attention:", 18, "#B0BEC5", FONT_R, (100, 285)), - ( - " 1. Mnożenie Q x K\u1d40 → macierz NxN (kto ważny dla kogo)", - 16, - "white", - FONT_R, - (120, 320), - ), - ( - " 2. Skalowanie: / √d (stabilność gradientów)", - 16, - "white", - FONT_R, - (120, 355), - ), - ( - " 3. Softmax → wagi attention (sumują się do 1)", - 16, - "white", - FONT_R, - (120, 390), - ), - ( - " 4. Mnożenie wag x V → ważona suma wartości", - 16, - "white", - FONT_R, - (120, 425), - ), - ( - "Attention(Q,K,V) = softmax(Q · K\u1d40 / √d) · V", - 20, - "#FFE082", - FONT_B, - (100, 480), - ), - ( - "Złożoność: O(n²) pamięci — n = liczba pikseli/tokenów", - 16, - "#EF9A9A", - FONT_R, - (100, 535), - ), - ( - "Dlatego SegFormer używa efficient attention (liniowa złożoność)", - 15, - "#78909C", - FONT_R, - (100, 570), - ), - ( - "SegFormer (2021): lightweight + hierarchiczny encoder", - 16, - "#A5D6A7", - FONT_R, - (100, 610), - ), - ( - "Mask2Former (2022): masked attention + " - "unified (semantic+instance+panoptic)", - 16, - "#CE93D8", - FONT_R, - (100, 645), - ), - ] - slides.append(_text_slide(qkv_lines, duration=STEP_DUR + 1)) - - # Slide 3: Encoder-Decoder in DL summary - summary_lines = [ - ( - "Podsumowanie: Encoder-Decoder w segmentacji DL", - 24, - "#FFE082", - FONT_B, - (80, 30), - ), - ("Wspólna idea WSZYSTKICH sieci segmentacji:", 18, "#B0BEC5", FONT_R, (80, 90)), - ( - "Encoder: obraz → cechy (zmniejsza rozdzielczość, wyciąga CO)", - 16, - "#64B5F6", - FONT_R, - (100, 140), - ), - ( - "Decoder: cechy → mapa (zwiększa rozdzielczość, odtwarza GDZIE)", - 16, - "#A5D6A7", - FONT_R, - (100, 175), - ), - ( - "Skip: przenosi detale z encodera do decodera", - 16, - "#FFE082", - FONT_R, - (100, 210), - ), - ("", 10, "white", FONT_R, (100, 240)), - ( - "FCN (2015): Conv1x1 + skip → pierwsza end-to-end", - 16, - "#64B5F6", - FONT_R, - (100, 275), - ), - ( - "U-Net (2015): U-shape + skip concat → segmentacja medyczna", - 16, - "#A5D6A7", - FONT_R, - (100, 310), - ), - ( - "DeepLab (2018): dilated conv + ASPP → multi-scale kontekst", - 16, - "#FFE082", - FONT_R, - (100, 345), - ), - ( - "SegFormer: transformer encoder (globalny kontekst)", - 16, - "#CE93D8", - FONT_R, - (100, 380), - ), - ( - "Mask2Former: masked attention (unified, SOTA)", - 16, - "#CE93D8", - FONT_R, - (100, 415), - ), - ("", 10, "white", FONT_R, (100, 440)), - ( - "Ewolucja: więcej kontekstu + lepsze skip connections:", - 17, - "white", - FONT_R, - (80, 465), - ), - ( - " CNN lokal. → dilated (szersze RF) → transformer (global) → masked att.", - 16, - "#B0BEC5", - FONT_R, - (80, 505), - ), - ( - " addition skip → concat skip → cross-attention skip", - 16, - "#B0BEC5", - FONT_R, - (80, 540), - ), - ( - "Metryki: mIoU (standard), Dice (medycyna), Focal Loss (imbalance)", - 16, - "#90CAF9", - FONT_R, - (80, 590), - ), - ( - "Loss: Cross-Entropy per piksel + opcjonalnie Dice/Focal", - 15, - "#78909C", - FONT_R, - (80, 625), - ), - ] - slides.append(_text_slide(summary_lines, duration=STEP_DUR + 1)) - - return slides - - -# ── Methods comparison ──────────────────────────────────────────── -def _methods_comparison() -> CompositeVideoClip: - bg = ColorClip(size=(W, H), color=BG_COLOR).with_duration(10.0) - title = ( - _tc( - text="Porównanie metod segmentacji", - font_size=36, - color="white", - font=FONT_B, - ) - .with_duration(10.0) - .with_position(("center", 20)) - ) - - rows = [ - ("Metoda", "Typ", "Idea", "Mnemonik"), - ("Thresholding", "Klasyczna", "piksel > T → klasa 1", "PRÓG na bramce"), - ("Otsu", "Klasyczna", "auto-próg, min σ²", "AUTO-bramkarz"), - ("Region Growing", "Klasyczna", "BFS od seeda", "PLAMA atramentu"), - ("Watershed", "Klasyczna", "zalewanie minimów", "ZALEWANIE terenu"), - ("Mean Shift", "Klasyczna", "jądro → max gęstości", "KULKI do dołków"), - ("U-Net", "Deep Learning", "encoder-decoder + skip", "Litera U + mosty"), - ("DeepLab", "Deep Learning", "dilated conv + ASPP", "DZIURY w filtrze"), - ] - - clips: list[VideoClip] = [bg, title] - mnemonic_col = 3 - for i, row in enumerate(rows): - y_pos = 75 + i * 72 - col_x = [40, 210, 340, 660] - for j, cell in enumerate(row): - fs = 16 if i > 0 else 18 - color = ( - "#64B5F6" if i == 0 - else ("#E0E0E0" if j < mnemonic_col else "#FFE082") - ) - tc = ( - _tc( - text=cell, - font_size=fs, - color=color, - font=FONT_B if i == 0 else FONT_R, - ) - .with_duration(10.0) - .with_position((col_x[j], y_pos)) - ) - clips.append(tc) - - return CompositeVideoClip(clips, size=(W, H)).with_effects( - [FadeIn(0.5), FadeOut(0.5)] - ) - - -# ── Main ────────────────────────────────────────────────────────── def main() -> None: """Generate the Q23 segmentation visualization video.""" sections: list[VideoClip] = [] diff --git a/python_pkg/praca_magisterska_video/visualize_q24.py b/python_pkg/praca_magisterska_video/visualize_q24.py index 5e3f1b5..b3e5370 100644 --- a/python_pkg/praca_magisterska_video/visualize_q24.py +++ b/python_pkg/praca_magisterska_video/visualize_q24.py @@ -13,1813 +13,28 @@ from __future__ import annotations import logging import os -from pathlib import Path - -import numpy as np os.environ["FFMPEG_BINARY"] = "/usr/bin/ffmpeg" -from moviepy import ( - ColorClip, - CompositeVideoClip, - TextClip, - VideoClip, - concatenate_videoclips, +from _q24_classical import ( + _detection_concept, + _hog_svm_demo, + _viola_jones_demo, ) -from moviepy.video.fx import FadeIn, FadeOut - -# ── Constants ───────────────────────────────────────────────────── -W, H = 1280, 720 -FPS = 24 -STEP_DUR = 7.0 -HEADER_DUR = 4.0 -FONT_B = "/usr/share/fonts/TTF/DejaVuSans-Bold.ttf" -FONT_R = "/usr/share/fonts/TTF/DejaVuSans.ttf" -OUTPUT_DIR = Path(__file__).resolve().parent / "videos" -OUTPUT_DIR.mkdir(parents=True, exist_ok=True) -OUTPUT = str(OUTPUT_DIR / "q24_object_detection.mp4") - -BG_COLOR = (15, 20, 35) - -_logger = logging.getLogger(__name__) - - -def _tc(**kwargs: object) -> TextClip: - """TextClip wrapper that adds enough bottom margin to prevent clipping.""" - fs = kwargs.get("font_size", 24) - m = int(fs) // 3 + 2 - kwargs["margin"] = (0, m) - return TextClip(**kwargs) - - -def _make_header( - title: str, subtitle: str, duration: float = HEADER_DUR -) -> CompositeVideoClip: - bg = ColorClip(size=(W, H), color=BG_COLOR).with_duration(duration) - t = ( - _tc( - text=title, - font_size=48, - color="white", - font=FONT_B, - ) - .with_duration(duration) - .with_position(("center", 260)) - ) - s = ( - _tc( - text=subtitle, - font_size=24, - color="#90CAF9", - font=FONT_R, - ) - .with_duration(duration) - .with_position(("center", 340)) - ) - return CompositeVideoClip([bg, t, s], size=(W, H)).with_effects( - [FadeIn(0.5), FadeOut(0.5)] - ) - - -# ── Detection concept ──────────────────────────────────────────── -def _detection_concept() -> list[CompositeVideoClip]: - """Show what detection is: bounding box + class + confidence.""" - slides = [] - - def make_det_frame(_t: float) -> np.ndarray: - frame = np.zeros((H, W, 3), dtype=np.uint8) - frame[:] = BG_COLOR - - # Draw a "scene" with colored rectangles representing objects - # Sky background area - frame[140:500, 100:700] = (40, 50, 70) - - # "Car" object - frame[350:430, 150:320] = (180, 60, 60) - # "Person" object - frame[280:440, 450:520] = (60, 120, 180) - # "Tree" object - frame[200:400, 580:650] = (40, 130, 50) - - # Bounding boxes (with labels drawn as colored borders) - # Car bbox - for thickness in range(3): - t = thickness - frame[348 - t : 432 + t, 148 - t : 148 - t + 2] = (255, 80, 80) - frame[348 - t : 432 + t, 322 + t - 2 : 322 + t] = (255, 80, 80) - frame[348 - t : 348 - t + 2, 148 - t : 322 + t] = (255, 80, 80) - frame[432 + t - 2 : 432 + t, 148 - t : 322 + t] = (255, 80, 80) - - # Person bbox - for thickness in range(3): - t = thickness - frame[278 - t : 442 + t, 448 - t : 448 - t + 2] = (80, 180, 255) - frame[278 - t : 442 + t, 522 + t - 2 : 522 + t] = (80, 180, 255) - frame[278 - t : 278 - t + 2, 448 - t : 522 + t] = (80, 180, 255) - frame[442 + t - 2 : 442 + t, 448 - t : 522 + t] = (80, 180, 255) - - # Tree bbox - for thickness in range(3): - t = thickness - frame[198 - t : 402 + t, 578 - t : 578 - t + 2] = (80, 220, 100) - frame[198 - t : 402 + t, 652 + t - 2 : 652 + t] = (80, 220, 100) - frame[198 - t : 198 - t + 2, 578 - t : 652 + t] = (80, 220, 100) - frame[402 + t - 2 : 402 + t, 578 - t : 652 + t] = (80, 220, 100) - - # Comparison boxes on right side - # Classification - frame[180:260, 800:1150] = (35, 45, 65) - # Detection - frame[290:370, 800:1150] = (35, 45, 65) - # Segmentation - frame[400:480, 800:1150] = (35, 45, 65) - - return frame - - det_clip = VideoClip(make_det_frame, duration=STEP_DUR).with_fps(FPS) - text_clips: list[VideoClip] = [det_clip] - labels = [ - ("Detekcja obiektów — co to jest?", 28, "#FFE082", FONT_B, (100, 20)), - ("Wynik: (klasa, bounding box, pewność)", 20, "#B0BEC5", FONT_R, (100, 65)), - ("samochód 95%", 14, "#EF9A9A", FONT_B, (150, 340)), - ("osoba 88%", 14, "#64B5F6", FONT_B, (450, 268)), - ("drzewo 72%", 14, "#A5D6A7", FONT_B, (580, 188)), - ("Klasyfikacja: cały obraz → 1 etykieta", 15, "#78909C", FONT_R, (810, 210)), - ("Detekcja: bbox + klasa + pewność", 15, "#FFE082", FONT_R, (810, 320)), - ("Segmentacja: maska per piksel", 15, "#78909C", FONT_R, (810, 430)), - ("← granulacja rośnie →", 14, "#90CAF9", FONT_R, (810, 520)), - ] - for text, fs, color, font, pos in labels: - tc = ( - _tc(text=text, font_size=fs, color=color, font=font) - .with_duration(STEP_DUR) - .with_position(pos) - ) - text_clips.append(tc) - - slides.append( - CompositeVideoClip(text_clips, size=(W, H)).with_effects( - [FadeIn(0.3), FadeOut(0.3)] - ) - ) - return slides - - -# ── HOG + SVM pipeline ─────────────────────────────────────────── -def _hog_svm_demo() -> list[CompositeVideoClip]: - """Animate HOG feature computation and SVM classification.""" - slides = [] - - def make_hog_frame(t: float) -> np.ndarray: - frame = np.zeros((H, W, 3), dtype=np.uint8) - frame[:] = BG_COLOR - - progress = min(t / (STEP_DUR * 0.8), 1.0) - - # Pipeline stages as boxes with arrows - stages = [ - ("Gradient", (80, 250), (130, 80), (100, 160, 220)), - ("Orientacja", (260, 250), (130, 80), (80, 180, 140)), - ("Komórki 8x8", (440, 250), (130, 80), (200, 160, 80)), - ("Bloki 2x2", (620, 250), (130, 80), (200, 120, 60)), - ("Normalizacja", (800, 250), (130, 80), (180, 100, 80)), - ("SVM", (980, 250), (130, 80), (220, 80, 80)), - ] - - n_active = int(progress * len(stages)) + 1 - - for i, (_label, (sx, sy), (sw, sh), color) in enumerate(stages): - if i < n_active: - frame[sy : sy + sh, sx : sx + sw] = color - # Border - frame[sy : sy + 2, sx : sx + sw] = tuple( - min(c + 60, 255) for c in color - ) - frame[sy + sh - 2 : sy + sh, sx : sx + sw] = tuple( - min(c + 60, 255) for c in color - ) - - # Arrow to next - if i < len(stages) - 1: - ax = sx + sw + 5 - ay = sy + sh // 2 - frame[ay - 1 : ay + 2, ax : ax + 20] = (150, 150, 170) - - # Show gradient computation example at bottom - gradient_phase = 0.2 - if progress > gradient_phase: - # Mini pixel grid showing gradient computation - gx, gy = 100, 430 - pixels = [50, 50, 200] - for idx, val in enumerate(pixels): - x = gx + idx * 50 - frame[gy : gy + 40, x : x + 40] = (val, val, val) - - return frame - - hog_clip = VideoClip(make_hog_frame, duration=STEP_DUR).with_fps(FPS) - text_clips: list[VideoClip] = [hog_clip] - labels = [ - ("HOG + SVM — pipeline detekcji pieszych", 28, "#FFE082", FONT_B, (80, 20)), - ( - "Mnemonik: GOKBN = Gradienty→Orientacja→Komórki→Bloki→Normalizacja", - 16, - "#A5D6A7", - FONT_R, - (80, 65), - ), - ("Gradient: siła i kierunek zmiany jasności", 14, "#64B5F6", FONT_R, (80, 95)), - ( - "Histogram: 9 binów (0°-180°, co 20°) per komórka 8x8", - 14, - "#78909C", - FONT_R, - (80, 120), - ), - ( - "[50][50][200] → Gx = 200-50 = 150 = silna krawędź!", - 16, - "#EF9A9A", - FONT_R, - (80, 490), - ), - ( - "Wektor HOG (3780 cech) → SVM: pieszy (+1) / tło (-1)", - 16, - "white", - FONT_R, - (80, 540), - ), - ( - "Sliding window 64x128 przesuwa się po obrazie → NMS → wynik", - 16, - "#90CAF9", - FONT_R, - (80, 580), - ), - ( - "SVM = LINIA MAKSYMALNEGO ODDECHU (max margines, support vectors)", - 16, - "#FFE082", - FONT_R, - (80, 620), - ), - ] - for text, fs, color, font, pos in labels: - tc = ( - _tc(text=text, font_size=fs, color=color, font=font) - .with_duration(STEP_DUR) - .with_position(pos) - ) - text_clips.append(tc) - - slides.append( - CompositeVideoClip(text_clips, size=(W, H)).with_effects( - [FadeIn(0.3), FadeOut(0.3)] - ) - ) - return slides - - -# ── Viola-Jones ─────────────────────────────────────────────────── -def _viola_jones_demo() -> list[CompositeVideoClip]: - """Animate Viola-Jones cascade concept.""" - slides = [] - - def make_cascade_frame(t: float) -> np.ndarray: - frame = np.zeros((H, W, 3), dtype=np.uint8) - frame[:] = BG_COLOR - - progress = min(t / (STEP_DUR * 0.8), 1.0) - - # Draw cascade "funnel" — stages filtering out non-faces - stages = 5 - start_width = 1000 - start_count = 10000 - x_center = W // 2 - - for i in range(stages): - stage_progress = min(progress * stages - i, 1.0) - if stage_progress <= 0: - break - - width = int(start_width * (1 - i * 0.18)) - int(start_count * (0.3**i)) - y = 150 + i * 100 - h_box = 60 - - # Stage box - x1 = x_center - width // 2 - frame[y : y + h_box, x1 : x1 + width] = ( - 50 + i * 10, - 60 + i * 10, - 80 + i * 10, - ) - # Border - frame[y : y + 2, x1 : x1 + width] = (100 + i * 20, 130 + i * 15, 200) - frame[y + h_box - 2 : y + h_box, x1 : x1 + width] = ( - 100 + i * 20, - 130 + i * 15, - 200, - ) - - # Arrow down to next - if i < stages - 1: - frame[y + h_box + 5 : y + h_box + 25, x_center - 1 : x_center + 2] = ( - 150, - 150, - 170, - ) - - # Red "rejected" arrows on sides - if i > 0: - # Left reject arrow - rx = x1 - 30 - ry = y + h_box // 2 - frame[ry - 1 : ry + 2, rx : rx + 25] = (200, 80, 80) - - return frame - - cascade_clip = VideoClip(make_cascade_frame, duration=STEP_DUR).with_fps(FPS) - text_clips: list[VideoClip] = [cascade_clip] - labels = [ - ( - "Viola-Jones — kaskada klasyfikatorów (2001)", - 28, - "#FFE082", - FONT_B, - (80, 20), - ), - ( - "3 innowacje: HIC = Haar + Integral Image + Cascade", - 20, - "#B0BEC5", - FONT_R, - (80, 65), - ), - ("Etap 1: 2 cechy Haar", 14, "#64B5F6", FONT_R, (170, 170)), - ("Etap 2: 10 cech", 14, "#64B5F6", FONT_R, (210, 270)), - ("Etap 3: 25 cech", 14, "#64B5F6", FONT_R, (240, 370)), - ("Etap 4: 50 cech", 14, "#64B5F6", FONT_R, (260, 470)), - ("→ TWARZ!", 16, "#A5D6A7", FONT_B, (590, 560)), - ( - "SITO: 99% okien odpada w pierwszych 3 etapach → REAL-TIME!", - 16, - "#EF9A9A", - FONT_R, - (80, 620), - ), - ( - "Haar: kontrast jasna/ciemna | Integral Image: " - "suma prostokąta O(1) = 4 odczyty", - 14, - "#78909C", - FONT_R, - (80, 655), - ), - ("odrzucone →", 12, "#EF9A9A", FONT_R, (60, 275)), - ("odrzucone →", 12, "#EF9A9A", FONT_R, (60, 375)), - ] - for text, fs, color, font, pos in labels: - tc = ( - _tc(text=text, font_size=fs, color=color, font=font) - .with_duration(STEP_DUR) - .with_position(pos) - ) - text_clips.append(tc) - - slides.append( - CompositeVideoClip(text_clips, size=(W, H)).with_effects( - [FadeIn(0.3), FadeOut(0.3)] - ) - ) - return slides - - -# ── R-CNN Evolution ─────────────────────────────────────────────── -def _rcnn_evolution() -> list[CompositeVideoClip]: - """Animate R-CNN → Fast R-CNN → Faster R-CNN evolution.""" - slides = [] - - def make_evolution_frame(t: float) -> np.ndarray: - frame = np.zeros((H, W, 3), dtype=np.uint8) - frame[:] = BG_COLOR - - progress = min(t / (STEP_DUR * 0.8), 1.0) - - # Three rows: R-CNN, Fast R-CNN, Faster R-CNN - models = [ - ( - "R-CNN (2014)", - 50, - [ - ("Selective\nSearch", (200, 150), (100, 50), (120, 100, 60)), - ("2000x\nCNN", (350, 150), (80, 50), (180, 60, 60)), - ("2000x\nSVM", (480, 150), (80, 50), (180, 60, 60)), - ("NMS", (610, 150), (60, 50), (100, 140, 100)), - ], - "50 sec/obraz!", - ), - ( - "Fast R-CNN (2015)", - 300, - [ - ("Selective\nSearch", (200, 150), (100, 50), (120, 100, 60)), - ("1x CNN\n(cały obraz)", (350, 150), (100, 50), (80, 140, 200)), - ("ROI Pool\n(2000)", (500, 150), (90, 50), (200, 160, 80)), - ("FC", (640, 150), (50, 50), (100, 140, 100)), - ], - "2 sec/obraz", - ), - ( - "Faster R-CNN (2015)", - 300, - [ - ("CNN\nbackbone", (200, 150), (90, 50), (80, 140, 200)), - ("RPN\n(~300)", (340, 150), (80, 50), (200, 120, 60)), - ("ROI Pool", (470, 150), (80, 50), (200, 160, 80)), - ("FC", (600, 150), (50, 50), (100, 140, 100)), - ], - "0.2 sec → 5 fps!", - ), - ] - - n_models = int(progress * 3) + 1 - - for mi, (_name, base_y, stages, _speed) in enumerate(models): - if mi >= n_models: - break - for _label, (bx, by_off), (bw, bh), color in stages: - by = base_y + by_off - 150 - frame[by : by + bh, bx : bx + bw] = color - frame[by : by + 2, bx : bx + bw] = tuple( - min(c + 50, 255) for c in color - ) - frame[by + bh - 2 : by + bh, bx : bx + bw] = tuple( - min(c + 50, 255) for c in color - ) - - # Arrows between stages - for si in range(len(stages) - 1): - sx = stages[si][1][0] + stages[si][2][0] - ex = stages[si + 1][1][0] - ay = base_y + 25 - frame[ay - 1 : ay + 2, sx + 3 : ex - 3] = (150, 150, 170) - - return frame - - evo_clip = VideoClip(make_evolution_frame, duration=STEP_DUR + 1).with_fps(FPS) - text_clips: list[VideoClip] = [evo_clip] - labels = [ - ("Ewolucja R-CNN — CORAZ MNIEJ MARNOWANIA", 28, "#FFE082", FONT_B, (80, 20)), - ("R-CNN (2014)", 20, "#EF9A9A", FONT_B, (50, 80)), - ("50 sec/obraz (2000x forward pass!)", 14, "#EF9A9A", FONT_R, (720, 100)), - ("Fast R-CNN (2015)", 20, "#64B5F6", FONT_B, (50, 330)), - ("2 sec/obraz (CNN raz + ROI Pool)", 14, "#64B5F6", FONT_R, (720, 350)), - ("Faster R-CNN (2015)", 20, "#A5D6A7", FONT_B, (50, 580)), - ("0.2 sec → 5 fps (RPN w sieci!)", 14, "#A5D6A7", FONT_R, (720, 600)), - ( - "Kluczowe innowacje: ROI Pooling → stały rozmiar " - "| RPN → propozycje w sieci", - 14, - "#78909C", - FONT_R, - (80, 660), - ), - ] - for text, fs, color, font, pos in labels: - tc = ( - _tc(text=text, font_size=fs, color=color, font=font) - .with_duration(STEP_DUR + 1) - .with_position(pos) - ) - text_clips.append(tc) - - slides.append( - CompositeVideoClip(text_clips, size=(W, H)).with_effects( - [FadeIn(0.3), FadeOut(0.3)] - ) - ) - return slides - - -# ── R-CNN Detailed Pipeline ────────────────────────────────────── -def _rcnn_detailed() -> list[CompositeVideoClip]: - """Animate R-CNN step-by-step pipeline in detail.""" - slides = [] - - # Slide 1: R-CNN pipeline step by step - def make_rcnn_pipeline(t: float) -> np.ndarray: - frame = np.zeros((H, W, 3), dtype=np.uint8) - frame[:] = BG_COLOR - progress = min(t / (STEP_DUR * 0.8), 1.0) - - # Step boxes arranged vertically with arrows - steps = [ - ((80, 130), (200, 55), (120, 100, 60), "1. Selective Search"), - ((80, 230), (200, 55), (180, 60, 60), "2. Wytnij 2000 regionów"), - ((80, 330), (200, 55), (70, 130, 200), "3. CNN per region"), - ((80, 430), (200, 55), (200, 100, 80), "4. SVM klasyfikuje"), - ((80, 530), (200, 55), (100, 180, 100), "5. Bbox regresja + NMS"), - ] - n_steps = min(int(progress * 5) + 1, 5) - for i, ((bx, by), (bw, bh), color, _lbl) in enumerate(steps): - if i < n_steps: - frame[by : by + bh, bx : bx + bw] = color - frame[by : by + 2, bx : bx + bw] = tuple( - min(c + 50, 255) for c in color - ) - frame[by + bh - 2 : by + bh, bx : bx + bw] = tuple( - min(c + 50, 255) for c in color - ) - # Arrow down - arrow_limit = 4 - if i < arrow_limit: - ax = bx + bw // 2 - ay = by + bh + 5 - frame[ay : ay + 20, ax - 1 : ax + 2] = (150, 150, 170) - - # Illustration: many overlapping regions from Selective Search - overlay_phase = 0.2 - if progress > overlay_phase: - rng_local = np.random.default_rng(42) - n_boxes = min(int((progress - 0.2) * 15), 8) - for i in range(n_boxes): - rx = 500 + rng_local.integers(-30, 100) - ry = 200 + rng_local.integers(-20, 120) - rw = 60 + rng_local.integers(0, 80) - rh = 50 + rng_local.integers(0, 70) - c = (80 + i * 15, 100 + i * 10, 60 + i * 20) - for tt in range(2): - frame[ry - tt : ry + rh + tt, rx - tt : rx - tt + 2] = c - frame[ry - tt : ry + rh + tt, rx + rw + tt - 2 : rx + rw + tt] = c - frame[ry - tt : ry - tt + 2, rx - tt : rx + rw + tt] = c - frame[ry + rh + tt - 2 : ry + rh + tt, rx - tt : rx + rw + tt] = c - - return frame - - rcnn_clip = VideoClip(make_rcnn_pipeline, duration=STEP_DUR + 1).with_fps(FPS) - dur = STEP_DUR + 1 - labels = [ - ("R-CNN: krok po kroku (2014, Girshick)", 26, "#FFE082", FONT_B, (80, 20)), - ("Pipeline detekcji two-stage", 16, "#B0BEC5", FONT_R, (80, 60)), - ("Selective Search", 11, "white", FONT_R, (105, 145)), - ("2000 regionów", 11, "white", FONT_R, (105, 245)), - ("CNN per region", 11, "white", FONT_R, (105, 345)), - ("SVM klasyfikuje", 11, "white", FONT_R, (105, 445)), - ("Regresja + NMS", 11, "white", FONT_R, (105, 545)), - ("~2000 propozycji regionów", 14, "#78909C", FONT_R, (500, 155)), - ("(inteligentne łączenie", 13, "#78909C", FONT_R, (500, 180)), - ("podobnych fragmentów)", 13, "#78909C", FONT_R, (500, 200)), - ("Problem: 2000 x CNN forward pass", 16, "#EF9A9A", FONT_R, (400, 400)), - ("= 50 SEKUND na obraz!", 18, "#EF9A9A", FONT_B, (400, 430)), - ("CNN liczy cechy per region OSOBNO", 14, "#EF9A9A", FONT_R, (400, 470)), - ( - "→ regiony się nakładają → obliczenia się powtarzają!", - 14, - "#EF9A9A", - FONT_R, - (400, 495), - ), - ( - "Rozwiązanie: CNN raz na cały obraz → Fast R-CNN →", - 16, - "#A5D6A7", - FONT_R, - (80, 620), - ), - ] - text_clips: list[VideoClip] = [rcnn_clip] - for text, fs, color, font, pos in labels: - tc = ( - _tc(text=text, font_size=fs, color=color, font=font) - .with_duration(dur) - .with_position(pos) - ) - text_clips.append(tc) - slides.append( - CompositeVideoClip(text_clips, size=(W, H)).with_effects( - [FadeIn(0.3), FadeOut(0.3)] - ) - ) - - return slides - - -# ── ROI Pooling ────────────────────────────────────────────────── - - -def _draw_roi_pool_grid(frame: np.ndarray) -> None: - """Draw the 3x3 ROI pool grid with max-pooled feature values.""" - out_x, out_y = 400, 220 - out_cell = 50 - out_n = 3 - roi_r1, roi_c1 = 2, 1 - roi_r2, roi_c2 = 6, 5 - roi_h = roi_r2 - roi_r1 - roi_w = roi_c2 - roi_c1 - for r in range(out_n): - for c in range(out_n): - x = out_x + c * out_cell - y = out_y + r * out_cell - - # Compute the max from corresponding region - src_r1 = roi_r1 + r * roi_h // out_n - src_r2 = roi_r1 + (r + 1) * roi_h // out_n - src_c1 = roi_c1 + c * roi_w // out_n - src_c2 = roi_c1 + (c + 1) * roi_w // out_n - max_val = 0 - for sr in range(src_r1, src_r2): - for sc in range(src_c1, src_c2): - v = 30 + ((sr * 7 + sc * 13 + 42) % 40) - max_val = max(max_val, v) - - frame[y : y + out_cell - 2, x : x + out_cell - 2] = ( - max_val, - max_val + 20, - max_val + 40, - ) - frame[y : y + 2, x : x + out_cell - 2] = (80, 200, 120) - frame[y + out_cell - 4 : y + out_cell - 2, x : x + out_cell - 2] = ( - 80, - 200, - 120, - ) - - -def _make_roi_frame(t: float) -> np.ndarray: - """Render a single frame for the ROI pooling animation.""" - frame = np.zeros((H, W, 3), dtype=np.uint8) - frame[:] = BG_COLOR - progress = min(t / (STEP_DUR * 0.7), 1.0) - - # Left: feature map with ROI highlighted - fm_x, fm_y = 60, 180 - fm_cell = 30 - fm_grid = 8 - for r in range(fm_grid): - for c in range(fm_grid): - x = fm_x + c * fm_cell - y = fm_y + r * fm_cell - # Random-looking feature values - val = 30 + ((r * 7 + c * 13 + 42) % 40) - frame[y : y + fm_cell - 1, x : x + fm_cell - 1] = ( - val, - val + 10, - val + 20, - ) - - # ROI region highlighted - roi_r1, roi_c1 = 2, 1 - roi_r2, roi_c2 = 6, 5 - for tt in range(3): - ry1 = fm_y + roi_r1 * fm_cell - tt - ry2 = fm_y + roi_r2 * fm_cell + tt - rx1 = fm_x + roi_c1 * fm_cell - tt - rx2 = fm_x + roi_c2 * fm_cell + tt - frame[ry1:ry2, rx1 : rx1 + 2] = (255, 200, 50) - frame[ry1:ry2, rx2 - 2 : rx2] = (255, 200, 50) - frame[ry1 : ry1 + 2, rx1:rx2] = (255, 200, 50) - frame[ry2 - 2 : ry2, rx1:rx2] = (255, 200, 50) - - # Arrow - arrow_phase = 0.3 - if progress > arrow_phase: - frame[300:303, 310:380] = (150, 150, 170) - - # Middle: ROI divided into 3x3 grid (output_size) - grid_phase = 0.3 - if progress > grid_phase: - _draw_roi_pool_grid(frame) - - # Arrow to FC - fc_phase = 0.6 - if progress > fc_phase: - frame[300:303, 560:630] = (150, 150, 170) - # FC box - frame[270:340, 650:730] = (200, 100, 80) - frame[270:272, 650:730] = (240, 140, 120) - frame[338:340, 650:730] = (240, 140, 120) - - return frame - - -def _roi_pooling_demo() -> list[CompositeVideoClip]: - """Animate ROI Pooling: key Fast R-CNN innovation.""" - slides = [] - - roi_clip = VideoClip(_make_roi_frame, duration=STEP_DUR + 1).with_fps(FPS) - dur = STEP_DUR + 1 - labels = [ - ("ROI Pooling: kluczowa innowacja Fast R-CNN", 26, "#FFE082", FONT_B, (80, 20)), - ( - "KROK 1: CNN raz na CAŁY obraz → feature mapa", - 17, - "#64B5F6", - FONT_R, - (80, 60), - ), - ( - "KROK 2: Wytnij ROI z feature mapy (nie z obrazu!)", - 17, - "#FFE082", - FONT_R, - (80, 90), - ), - ( - "KROK 3: Siatkuj ROI na 3x3 → max pool per komórka → stały rozmiar", - 17, - "#A5D6A7", - FONT_R, - (80, 120), - ), - ("Feature mapa", 14, "#64B5F6", FONT_B, (60, 160)), - ("ROI (żółta ramka)", 13, "#FFE082", FONT_R, (60, 440)), - ("ROI Pool 3x3", 14, "#A5D6A7", FONT_B, (400, 195)), - ("(max z komórki)", 13, "#78909C", FONT_R, (400, 380)), - ("FC", 14, "white", FONT_B, (670, 280)), - ( - "Problem: ROI mają RÓŻNE rozmiary, FC wymaga STAŁEGO", - 15, - "#B0BEC5", - FONT_R, - (80, 500), - ), - ( - "ROI Pooling: dzieli ROI na siatkę, max pool → STAŁY rozmiar!", - 16, - "white", - FONT_R, - (80, 535), - ), - ( - "Fast R-CNN: CNN raz → 1 feature mapa → " - "ROI Pool 2000 regionów → 25x szybciej!", - 16, - "#A5D6A7", - FONT_R, - (80, 580), - ), - ( - "(R-CNN: 2000x CNN = 50s | Fast R-CNN: 1xCNN + ROI Pool = 2s)", - 15, - "#EF9A9A", - FONT_R, - (80, 620), - ), - ] - text_clips: list[VideoClip] = [roi_clip] - for text, fs, color, font, pos in labels: - tc = ( - _tc(text=text, font_size=fs, color=color, font=font) - .with_duration(dur) - .with_position(pos) - ) - text_clips.append(tc) - slides.append( - CompositeVideoClip(text_clips, size=(W, H)).with_effects( - [FadeIn(0.3), FadeOut(0.3)] - ) - ) - return slides - - -# ── RPN + Anchor Boxes ─────────────────────────────────────────── -def _rpn_anchors_demo() -> list[CompositeVideoClip]: - """Animate RPN and anchor boxes: Faster R-CNN innovation.""" - slides = [] - - # Slide 1: Anchor boxes concept - def make_anchors_frame(t: float) -> np.ndarray: - frame = np.zeros((H, W, 3), dtype=np.uint8) - frame[:] = BG_COLOR - progress = min(t / (STEP_DUR * 0.7), 1.0) - - # Draw feature map grid point with multiple anchors - cx, cy = 350, 360 # center point on feature map - - # Draw a "feature map" grid background - cell = 60 - for r in range(-3, 4): - for c in range(-3, 4): - x = cx + c * cell - cell // 2 - y = cy + r * cell - cell // 2 - frame[y : y + cell - 1, x : x + cell - 1] = (30, 35, 48) - - # Center point highlighted - frame[cy - 5 : cy + 5, cx - 5 : cx + 5] = (255, 200, 50) - - # Draw anchors around center: 3 sizes x 3 ratios = 9 - anchor_specs = [ - (30, 30, (200, 80, 80)), # small 1:1 - (20, 40, (200, 60, 60)), # small 1:2 - (40, 20, (180, 60, 60)), # small 2:1 - (60, 60, (80, 200, 80)), # medium 1:1 - (40, 80, (60, 180, 60)), # medium 1:2 - (80, 40, (60, 160, 60)), # medium 2:1 - (90, 90, (80, 80, 200)), # large 1:1 - (60, 120, (60, 60, 180)), # large 1:2 - (120, 60, (60, 60, 160)), # large 2:1 - ] - n_anchors = min(int(progress * 9) + 1, 9) - for i in range(n_anchors): - hw, hh, color = anchor_specs[i] - x1 = max(0, cx - hw) - y1 = max(0, cy - hh) - x2 = min(W - 1, cx + hw) - y2 = min(H - 1, cy + hh) - for tt in range(2): - frame[y1 - tt : y2 + tt, x1 - tt : x1 - tt + 2] = color - frame[y1 - tt : y2 + tt, x2 + tt - 2 : x2 + tt] = color - frame[y1 - tt : y1 - tt + 2, x1 - tt : x2 + tt] = color - frame[y2 + tt - 2 : y2 + tt, x1 - tt : x2 + tt] = color - - return frame - - anch_clip = VideoClip(make_anchors_frame, duration=STEP_DUR + 1).with_fps(FPS) - dur = STEP_DUR + 1 - labels = [ - ("Anchor Boxes + RPN (Faster R-CNN)", 26, "#FFE082", FONT_B, (80, 20)), - ( - "KROK 1: Anchory = predefiniowane kształty w każdej pozycji", - 17, - "#A5D6A7", - FONT_R, - (80, 60), - ), - ( - "3 rozmiary x 3 proporcje = 9 anchorów per punkt", - 16, - "#B0BEC5", - FONT_R, - (80, 90), - ), - ("Małe (1:1, 1:2, 2:1)", 14, "#EF9A9A", FONT_R, (750, 170)), - ("Średnie (1:1, 1:2, 2:1)", 14, "#A5D6A7", FONT_R, (750, 210)), - ("Duże (1:1, 1:2, 2:1)", 14, "#64B5F6", FONT_R, (750, 250)), - ("Żółty punkt = pozycja", 14, "#FFE082", FONT_R, (750, 310)), - ("na feature mapie", 14, "#FFE082", FONT_R, (750, 335)), - ("Sieć NIE predykuje bbox od zera!", 16, "white", FONT_R, (80, 530)), - ( - "Predykuje OFFSET od najbliższego anchora: (Δx, Δy, Δw, Δh)", - 16, - "#FFE082", - FONT_R, - (80, 565), - ), - ( - "+ P(obiekt) = 'czy w tym anchorze jest coś?'", - 16, - "#A5D6A7", - FONT_R, - (80, 600), - ), - ( - "Mnemonik: Anchor = KOTWICA — sieć dopasowuje bbox do kotwicy", - 15, - "#78909C", - FONT_R, - (80, 645), - ), - ] - text_clips: list[VideoClip] = [anch_clip] - for text, fs, color, font, pos in labels: - tc = ( - _tc(text=text, font_size=fs, color=color, font=font) - .with_duration(dur) - .with_position(pos) - ) - text_clips.append(tc) - slides.append( - CompositeVideoClip(text_clips, size=(W, H)).with_effects( - [FadeIn(0.3), FadeOut(0.3)] - ) - ) - - # Slide 2: RPN step by step - rpn_lines = [ - ( - "RPN: Region Proposal Network — krok po kroku", - 24, - "#FFE082", - FONT_B, - (80, 30), - ), - ( - "Zastępuje Selective Search SIECIĄ NEURONOWĄ (end-to-end!)", - 17, - "#B0BEC5", - FONT_R, - (80, 85), - ), - ("", 10, "white", FONT_R, (80, 110)), - ( - "1. Backbone (ResNet) przetwarza obraz → feature mapa [40x60x256]", - 16, - "#64B5F6", - FONT_R, - (100, 140), - ), - ( - "2. Filtr 3x3 przesuwa się po feature mapie", - 16, - "#A5D6A7", - FONT_R, - (100, 180), - ), - ( - "3. W KAŻDEJ pozycji (x,y) rozważ k=9 anchorów:", - 16, - "#FFE082", - FONT_R, - (100, 220), - ), - (" → P(obiekt) — 'czy tu jest coś?'", 15, "white", FONT_R, (120, 255)), - (" → (Δx, Δy, Δw, Δh) — poprawka pozycji", 15, "white", FONT_R, (120, 285)), - ( - "4. 40x60 pozycji x 9 anchorów = 21 600 kandydatów!", - 16, - "#EF9A9A", - FONT_R, - (100, 325), - ), - ( - "5. Weź ~300 z najwyższym P(obiekt) → ROI Pool → FC", - 16, - "#A5D6A7", - FONT_R, - (100, 365), - ), - ("", 10, "white", FONT_R, (100, 395)), - ("Porównanie generowania propozycji:", 17, "white", FONT_B, (80, 420)), - ( - " Selective Search: ~2000 regionów, osobny algorytm, ~2 sec", - 15, - "#EF9A9A", - FONT_R, - (100, 460), - ), - ( - " RPN: ~300 regionów, W SIECI, ~10 ms → 200x szybciej!", - 15, - "#A5D6A7", - FONT_R, - (100, 495), - ), - ("", 10, "white", FONT_R, (100, 520)), - ( - "Faster R-CNN = Backbone + RPN + ROI Pool + FC — WSZYSTKO end-to-end", - 17, - "#FFE082", - FONT_R, - (80, 545), - ), - ( - "→ 5 fps (0.2 sec/obraz) vs R-CNN 50 sec = 250x szybciej!", - 17, - "#A5D6A7", - FONT_R, - (80, 585), - ), - ( - "Wciąż two-stage: (1) RPN generuje propozycje, (2) FC klasyfikuje", - 15, - "#78909C", - FONT_R, - (80, 630), - ), - ] - slides.append(_text_slide(rpn_lines, duration=STEP_DUR + 1)) - - return slides - - -# ── YOLO ────────────────────────────────────────────────────────── -def _yolo_demo() -> list[CompositeVideoClip]: - """Animate YOLO grid detection concept.""" - slides = [] - - def make_yolo_frame(t: float) -> np.ndarray: - frame = np.zeros((H, W, 3), dtype=np.uint8) - frame[:] = BG_COLOR - - progress = min(t / (STEP_DUR * 0.7), 1.0) - - # Draw image with grid overlay - img_x, img_y = 100, 140 - img_size = 420 - grid_n = 7 - - # Background "image" - frame[img_y : img_y + img_size, img_x : img_x + img_size] = (50, 55, 70) - - # Objects in the image - frame[img_y + 80 : img_y + 200, img_x + 50 : img_x + 180] = ( - 180, - 60, - 60, - ) # "car" - frame[img_y + 150 : img_y + 350, img_x + 250 : img_x + 330] = ( - 60, - 120, - 180, - ) # "person" - - # Grid lines - cell = img_size // grid_n - for i in range(grid_n + 1): - # Vertical - x = img_x + i * cell - frame[img_y : img_y + img_size, x : x + 1] = (100, 100, 120) - # Horizontal - y = img_y + i * cell - frame[y : y + 1, img_x : img_x + img_size] = (100, 100, 120) - - # Highlight cells containing object centers - car_phase = 0.3 - if progress > car_phase: - # Car center ~ cell (1, 1) - cx, cy = 1, 2 - hx = img_x + cx * cell - hy = img_y + cy * cell - frame[hy : hy + cell, hx : hx + cell] = np.clip( - frame[hy : hy + cell, hx : hx + cell].astype(int) + 40, 0, 255 - ).astype(np.uint8) - - person_phase = 0.5 - if progress > person_phase: - # Person center ~ cell (4, 4) - cx, cy = 4, 4 - hx = img_x + cx * cell - hy = img_y + cy * cell - frame[hy : hy + cell, hx : hx + cell] = np.clip( - frame[hy : hy + cell, hx : hx + cell].astype(int) + 40, 0, 255 - ).astype(np.uint8) - - # Bounding boxes predictions from cells - bbox_phase = 0.6 - if progress > bbox_phase: - # Car bbox - for tt in range(2): - frame[ - img_y + 78 - tt : img_y + 202 + tt, - img_x + 48 - tt : img_x + 48 - tt + 2, - ] = (255, 80, 80) - frame[ - img_y + 78 - tt : img_y + 202 + tt, - img_x + 182 + tt - 2 : img_x + 182 + tt, - ] = (255, 80, 80) - frame[ - img_y + 78 - tt : img_y + 78 - tt + 2, - img_x + 48 - tt : img_x + 182 + tt, - ] = (255, 80, 80) - frame[ - img_y + 202 + tt - 2 : img_y + 202 + tt, - img_x + 48 - tt : img_x + 182 + tt, - ] = (255, 80, 80) - - # Person bbox - for tt in range(2): - frame[ - img_y + 148 - tt : img_y + 352 + tt, - img_x + 248 - tt : img_x + 248 - tt + 2, - ] = (80, 180, 255) - frame[ - img_y + 148 - tt : img_y + 352 + tt, - img_x + 332 + tt - 2 : img_x + 332 + tt, - ] = (80, 180, 255) - frame[ - img_y + 148 - tt : img_y + 148 - tt + 2, - img_x + 248 - tt : img_x + 332 + tt, - ] = (80, 180, 255) - frame[ - img_y + 352 + tt - 2 : img_y + 352 + tt, - img_x + 248 - tt : img_x + 332 + tt, - ] = (80, 180, 255) - - return frame - - yolo_clip = VideoClip(make_yolo_frame, duration=STEP_DUR).with_fps(FPS) - text_clips: list[VideoClip] = [yolo_clip] - labels = [ - ("YOLO — You Only Look Once", 28, "#FFE082", FONT_B, (80, 20)), - ( - "Jednoetapowy detektor: siatka SxS → wszystkie detekcje naraz!", - 18, - "#B0BEC5", - FONT_R, - (80, 65), - ), - ("Siatka 7x7 = 49 komórek", 16, "#64B5F6", FONT_R, (600, 180)), - ("Każda komórka predykuje:", 16, "white", FONT_R, (600, 220)), - (" • B bbox (x, y, w, h, conf)", 14, "#B0BEC5", FONT_R, (600, 255)), - (" • C klas (prawdopodobieństwa)", 14, "#B0BEC5", FONT_R, (600, 285)), - ("Komórka odpowiada za obiekt", 14, "#A5D6A7", FONT_R, (600, 325)), - ("którego ŚRODEK w niej wpada", 14, "#A5D6A7", FONT_R, (600, 350)), - ("45-155 fps! (vs 5 fps Faster R-CNN)", 18, "#EF9A9A", FONT_B, (600, 400)), - ( - "Jedno przejście przez sieć → WSZYSTKIE detekcje naraz → NMS → wynik", - 14, - "#78909C", - FONT_R, - (80, 620), - ), - ( - "Two-stage (R-CNN): propozycje+klasyfikacja " - "| One-stage (YOLO): bez propozycji!", - 14, - "#90CAF9", - FONT_R, - (80, 655), - ), - ] - for text, fs, color, font, pos in labels: - tc = ( - _tc(text=text, font_size=fs, color=color, font=font) - .with_duration(STEP_DUR) - .with_position(pos) - ) - text_clips.append(tc) - - slides.append( - CompositeVideoClip(text_clips, size=(W, H)).with_effects( - [FadeIn(0.3), FadeOut(0.3)] - ) - ) - return slides - - -# ── YOLO Architecture Detail ────────────────────────────────────── -def _yolo_architecture() -> list[CompositeVideoClip]: - """Show YOLO architecture: backbone → head, output tensor.""" - slides = [] - - # Slide 1: YOLO architecture breakdown - def make_yolo_arch(t: float) -> np.ndarray: - frame = np.zeros((H, W, 3), dtype=np.uint8) - frame[:] = BG_COLOR - progress = min(t / (STEP_DUR * 0.7), 1.0) - - # Pipeline: Image → Backbone → Neck → Head → SxSx(B*5+C) tensor - blocks = [ - ((60, 280), (100, 80), (50, 70, 90), "Obraz"), - ((200, 280), (100, 80), (70, 130, 200), "Backbone"), - ((340, 280), (100, 80), (200, 160, 80), "Neck"), - ((480, 280), (100, 80), (200, 100, 60), "Head"), - ((620, 280), (160, 80), (80, 200, 120), "SxSx(B*5+C)"), - ] - n_blocks = min(int(progress * 5) + 1, 5) - for i, ((bx, by), (bw, bh), color, _lbl) in enumerate(blocks): - if i < n_blocks: - frame[by : by + bh, bx : bx + bw] = color - frame[by : by + 2, bx : bx + bw] = tuple( - min(c + 50, 255) for c in color - ) - frame[by + bh - 2 : by + bh, bx : bx + bw] = tuple( - min(c + 50, 255) for c in color - ) - arrow_limit = 4 - if i < arrow_limit: - ax = bx + bw + 5 - ay = by + bh // 2 - frame[ay - 1 : ay + 2, ax : ax + 25] = (150, 150, 170) - - # Output tensor breakdown (right side) - tensor_phase = 0.6 - if progress > tensor_phase: - # Show SxS grid - gx, gy = 850, 180 - gs = 120 - gn = 4 # simplified from 7 - gc = gs // gn - for r in range(gn): - for c in range(gn): - x = gx + c * gc - y = gy + r * gc - frame[y : y + gc - 1, x : x + gc - 1] = (40, 50, 65) - # Highlight one cell - frame[gy + gc : gy + 2 * gc - 1, gx + gc : gx + 2 * gc - 1] = (80, 200, 120) - - return frame - - arch_clip = VideoClip(make_yolo_arch, duration=STEP_DUR + 1).with_fps(FPS) - dur = STEP_DUR + 1 - labels = [ - ("YOLO: Architektura — krok po kroku", 26, "#FFE082", FONT_B, (80, 20)), - ( - "One-stage: JEDEN forward pass → WSZYSTKIE detekcje naraz", - 17, - "#B0BEC5", - FONT_R, - (80, 60), - ), - ("Obraz", 13, "white", FONT_R, (85, 295)), - ("Backbone", 13, "white", FONT_R, (215, 295)), - ("(ResNet/", 11, "#78909C", FONT_R, (210, 370)), - ("Darknet)", 11, "#78909C", FONT_R, (210, 390)), - ("Neck", 13, "white", FONT_R, (365, 295)), - ("(FPN/", 11, "#78909C", FONT_R, (360, 370)), - ("PANet)", 11, "#78909C", FONT_R, (360, 390)), - ("Head", 13, "white", FONT_R, (505, 295)), - ("(conv)", 11, "#78909C", FONT_R, (500, 370)), - ("Tensor wyjścia", 13, "#A5D6A7", FONT_R, (640, 295)), - ("Każda komórka SxS predykuje:", 15, "#FFE082", FONT_R, (830, 320)), - (" B bbox x (x,y,w,h,conf)", 13, "#B0BEC5", FONT_R, (830, 350)), - (" + C klas (prob.)", 13, "#B0BEC5", FONT_R, (830, 375)), - ("= SxSx(Bx5+C) tensor", 13, "#A5D6A7", FONT_R, (830, 400)), - ("Np. 7x7x(2x5+20) = 7x7x30", 13, "#78909C", FONT_R, (830, 430)), - ( - "Two-stage (R-CNN): (1) propozycje → (2) klasyfikacja = 2 przejścia", - 15, - "#EF9A9A", - FONT_R, - (80, 470), - ), - ( - "One-stage (YOLO): siatka → predykcja all-in-one = 1 przejście!", - 15, - "#A5D6A7", - FONT_R, - (80, 505), - ), - ( - "Ewolucja YOLO: v1(2016)→v3→v5→v8(2023, anchor-free, SOTA)", - 16, - "#FFE082", - FONT_R, - (80, 555), - ), - ( - "SSD (2016): multi-scale feature maps → lepsza detekcja małych obiektów", - 15, - "#64B5F6", - FONT_R, - (80, 595), - ), - ( - "FPN: łączy wczesne warstwy (małe obiekty) + późne (duże obiekty)", - 15, - "#78909C", - FONT_R, - (80, 630), - ), - ] - text_clips: list[VideoClip] = [arch_clip] - for text, fs, color, font, pos in labels: - tc = ( - _tc(text=text, font_size=fs, color=color, font=font) - .with_duration(dur) - .with_position(pos) - ) - text_clips.append(tc) - slides.append( - CompositeVideoClip(text_clips, size=(W, H)).with_effects( - [FadeIn(0.3), FadeOut(0.3)] - ) - ) - - return slides - - -# ── DETR ────────────────────────────────────────────────────────── -def _detr_demo() -> list[CompositeVideoClip]: - """Animate DETR: transformer detection, object queries, no NMS.""" - slides = [] - - # Slide 1: DETR pipeline - def make_detr_frame(t: float) -> np.ndarray: - frame = np.zeros((H, W, 3), dtype=np.uint8) - frame[:] = BG_COLOR - progress = min(t / (STEP_DUR * 0.7), 1.0) - - # DETR pipeline: Image → Backbone → Encoder → Decoder → N predictions - blocks = [ - ((50, 260), (80, 60), (50, 70, 90)), - ((170, 260), (90, 60), (70, 130, 200)), - ((300, 260), (110, 60), (200, 120, 60)), - ((450, 260), (110, 60), (200, 80, 160)), - ((600, 260), (120, 60), (80, 200, 120)), - ] - n_blocks = min(int(progress * 5) + 1, 5) - for i, ((bx, by), (bw, bh), color) in enumerate(blocks): - if i < n_blocks: - frame[by : by + bh, bx : bx + bw] = color - frame[by : by + 2, bx : bx + bw] = tuple( - min(c + 50, 255) for c in color - ) - frame[by + bh - 2 : by + bh, bx : bx + bw] = tuple( - min(c + 50, 255) for c in color - ) - arrow_limit = 4 - if i < arrow_limit: - ax = bx + bw + 5 - ay = by + bh // 2 - frame[ay - 1 : ay + 2, ax : ax + 25] = (150, 150, 170) - - # Object queries illustration (right side) - query_phase = 0.5 - if progress > query_phase: - qx, qy = 800, 140 - for i in range(6): - y = qy + i * 50 - w = 130 - active_limit = 3 - active = i < active_limit - color = (80, 180, 120) if active else (60, 50, 50) - frame[y : y + 35, qx : qx + w] = color - frame[y : y + 1, qx : qx + w] = tuple(min(c + 40, 255) for c in color) - - # Arrow from decoder to queries - frame[285:288, 723:798] = (150, 150, 170) - - return frame - - detr_clip = VideoClip(make_detr_frame, duration=STEP_DUR + 1).with_fps(FPS) - dur = STEP_DUR + 1 - labels = [ - ("DETR: DEtection TRansformer (2020)", 26, "#FFE082", FONT_B, (80, 20)), - ( - "Radykalnie prostszy pipeline: BEZ anchorów, BEZ NMS!", - 17, - "#B0BEC5", - FONT_R, - (80, 60), - ), - ("Obraz", 12, "white", FONT_R, (65, 275)), - ("Backbone", 12, "white", FONT_R, (185, 275)), - ("Transformer", 12, "white", FONT_R, (310, 275)), - ("Encoder", 12, "white", FONT_R, (325, 295)), - ("Transformer", 12, "white", FONT_R, (460, 275)), - ("Decoder", 12, "white", FONT_R, (478, 295)), - ("N predykcji", 12, "white", FONT_R, (615, 275)), - ("Object Queries:", 14, "#FFE082", FONT_B, (800, 115)), - ("samochód 95%", 11, "white", FONT_R, (810, 148)), - ("pies 88%", 11, "white", FONT_R, (810, 198)), - ("rower 72%", 11, "white", FONT_R, (810, 248)), - ("brak", 11, "#78909C", FONT_R, (810, 298)), - ("brak", 11, "#78909C", FONT_R, (810, 348)), - ("brak", 11, "#78909C", FONT_R, (810, 398)), - ("100 wyuczonych queries", 13, "#FFE082", FONT_R, (800, 440)), - ("→ każdy 'szuka' obiektu", 13, "#FFE082", FONT_R, (800, 465)), - ] - text_clips: list[VideoClip] = [detr_clip] - for text, fs, color, font, pos in labels: - tc = ( - _tc(text=text, font_size=fs, color=color, font=font) - .with_duration(dur) - .with_position(pos) - ) - text_clips.append(tc) - slides.append( - CompositeVideoClip(text_clips, size=(W, H)).with_effects( - [FadeIn(0.3), FadeOut(0.3)] - ) - ) - - # Slide 2: Why no NMS + Hungarian matching - detr_details = [ - ("DETR: Dlaczego bez NMS? — krok po kroku", 24, "#FFE082", FONT_B, (80, 30)), - ( - "Problem NMS: duplikaty detekcji → ręcznie usuwaj post-hoc", - 16, - "#EF9A9A", - FONT_R, - (80, 90), - ), - ( - "DETR rozwiązanie: Hungarian matching (dopasowanie węgierskie)", - 17, - "#A5D6A7", - FONT_R, - (80, 130), - ), - ("", 10, "white", FONT_R, (80, 155)), - ("Jak to działa podczas TRENINGU:", 17, "white", FONT_B, (80, 180)), - (" 1. Sieć daje N=100 predykcji (queries)", 15, "#64B5F6", FONT_R, (100, 220)), - ( - " 2. Na obrazie jest np. 5 obiektów (ground truth)", - 15, - "#64B5F6", - FONT_R, - (100, 255), - ), - ( - " 3. Hungarian matching: optymalne dopasowanie 1:1", - 15, - "#FFE082", - FONT_R, - (100, 290), - ), - ( - " → query_1 ↔ gt_samochód (najlepsze dopasowanie)", - 14, - "#A5D6A7", - FONT_R, - (120, 325), - ), - (" → query_7 ↔ gt_pies", 14, "#A5D6A7", FONT_R, (120, 355)), - (" → query_3 ↔ gt_rower", 14, "#A5D6A7", FONT_R, (120, 385)), - ( - " → pozostałe 97 queries ↔ klasa 'brak obiektu'", - 14, - "#78909C", - FONT_R, - (120, 415), - ), - ( - " 4. Każdy obiekt ma DOKŁADNIE 1 predykcję → BRAK duplikatów!", - 15, - "#A5D6A7", - FONT_R, - (100, 455), - ), - ("", 10, "white", FONT_R, (100, 475)), - ( - "Self-attention w encoderze: cechy obrazu 'rozmawiają' ze sobą", - 15, - "#64B5F6", - FONT_R, - (80, 500), - ), - ( - "Cross-attention w decoderze: queries 'pytają' cechy obrazu", - 15, - "#CE93D8", - FONT_R, - (80, 535), - ), - ( - "→ query 'rozumie' który fragment obrazu to 'jego' obiekt", - 15, - "#FFE082", - FONT_R, - (80, 570), - ), - ( - "DETR = Detekcja Eliminująca Trikowe Redundancje (NMS, anchory)", - 16, - "#FFE082", - FONT_R, - (80, 620), - ), - ( - "Wada: wolniejszy trening (O(n²) attention) | Zaleta: prostszy pipeline!", - 15, - "#78909C", - FONT_R, - (80, 660), - ), - ] - slides.append(_text_slide(detr_details, duration=STEP_DUR + 1)) - - # Slide 3: Two-stage vs One-stage vs Transformer summary - summary_lines = [ - ( - "Podsumowanie: Two-stage vs One-stage vs Transformer", - 22, - "#FFE082", - FONT_B, - (80, 30), - ), - ("", 10, "white", FONT_R, (80, 55)), - ("TWO-STAGE (R-CNN family):", 18, "#EF9A9A", FONT_B, (80, 90)), - ( - " (1) Generuj propozycje → (2) Klasyfikuj per region", - 15, - "white", - FONT_R, - (100, 125), - ), - ( - " + Wysoka precyzja | - Wolniejsze (2 przejścia)", - 15, - "#78909C", - FONT_R, - (100, 155), - ), - ( - " R-CNN → Fast R-CNN → Faster R-CNN (0.2s)", - 15, - "#B0BEC5", - FONT_R, - (100, 185), - ), - ("", 10, "white", FONT_R, (80, 210)), - ("ONE-STAGE (YOLO, SSD):", 18, "#A5D6A7", FONT_B, (80, 240)), - ( - " Siatka → predykcja all-in-one (1 przejście)", - 15, - "white", - FONT_R, - (100, 275), - ), - ( - " + Bardzo szybkie (45-155 fps) | - Historycznie mniej precyzyjne", - 15, - "#78909C", - FONT_R, - (100, 305), - ), - ( - " YOLOv8 (2023): anchor-free, dorównuje two-stage!", - 15, - "#B0BEC5", - FONT_R, - (100, 335), - ), - ("", 10, "white", FONT_R, (80, 360)), - ("TRANSFORMER (DETR):", 18, "#CE93D8", FONT_B, (80, 390)), - ( - " Object queries + self-attention (globalny kontekst)", - 15, - "white", - FONT_R, - (100, 425), - ), - ( - " + Brak NMS/anchorów | - Wolniejszy trening (O(n²))", - 15, - "#78909C", - FONT_R, - (100, 455), - ), - ( - " Hungarian matching → 1:1 obiekt↔predykcja → brak duplikatów", - 15, - "#B0BEC5", - FONT_R, - (100, 485), - ), - ("", 10, "white", FONT_R, (80, 510)), - ( - "Trend: coraz prostsze pipeline, mniej ręcznych komponentów", - 17, - "white", - FONT_R, - (80, 540), - ), - ( - " R-CNN (SS+CNN+SVM+NMS) → YOLO " - "(backbone+head+NMS) → DETR (backbone+transformer)", - 14, - "#90CAF9", - FONT_R, - (80, 580), - ), - ( - "Metryki: mAP@0.5 (standard), mAP@0.5:0.95 (surowsza), IoU do dopasowania", - 15, - "#78909C", - FONT_R, - (80, 630), - ), - ] - slides.append(_text_slide(summary_lines, duration=STEP_DUR + 1)) - - return slides - - -# ── NMS + IoU ───────────────────────────────────────────────────── -def _nms_iou_demo() -> list[CompositeVideoClip]: - """Animate NMS and IoU concepts.""" - slides = [] - - def make_nms_frame(t: float) -> np.ndarray: - frame = np.zeros((H, W, 3), dtype=np.uint8) - frame[:] = BG_COLOR - - progress = min(t / (STEP_DUR * 0.7), 1.0) - - # Draw overlapping bounding boxes - ox, oy = 100, 200 - obj_w, obj_h = 150, 120 - - # Multiple overlapping detections for same object - boxes = [ - (ox, oy, obj_w, obj_h, 0.95, (255, 80, 80)), # best - (ox + 15, oy - 10, obj_w + 10, obj_h + 5, 0.90, (200, 60, 60)), - (ox - 10, oy + 5, obj_w - 5, obj_h + 10, 0.85, (160, 50, 50)), - ] - # Different object far away - boxes.append((ox + 350, oy + 50, 100, 100, 0.40, (80, 180, 255))) - - for i, (bx, by, bw, bh, _conf, color) in enumerate(boxes): - dc = color - nms_phase = 0.4 - nms_limit = 3 - if progress > nms_phase and i > 0 and i < nms_limit: - # After NMS, these get removed (shown as faded/crossed) - dc = (60, 40, 40) - - for tt in range(2): - frame[by - tt : by + bh + tt, bx - tt : bx - tt + 2] = dc - frame[by - tt : by + bh + tt, bx + bw + tt - 2 : bx + bw + tt] = dc - frame[by - tt : by - tt + 2, bx - tt : bx + bw + tt] = dc - frame[by + bh + tt - 2 : by + bh + tt, bx - tt : bx + bw + tt] = dc - - # IoU visualization on right side - iou_x, iou_y = 700, 200 - # Box A - frame[iou_y : iou_y + 100, iou_x : iou_x + 100] = (80, 80, 200) - # Box B (overlapping) - frame[iou_y + 40 : iou_y + 140, iou_x + 40 : iou_x + 140] = (200, 80, 80) - # Intersection highlighted - frame[iou_y + 40 : iou_y + 100, iou_x + 40 : iou_x + 100] = (200, 150, 200) - - return frame - - nms_clip = VideoClip(make_nms_frame, duration=STEP_DUR).with_fps(FPS) - text_clips: list[VideoClip] = [nms_clip] - labels = [ - ("NMS (Non-Maximum Suppression) + IoU", 28, "#FFE082", FONT_B, (80, 20)), - ( - "NMS = Najlepszy Ma Się dobrze — zachowaj najlepszą, usuń duplikaty", - 18, - "#B0BEC5", - FONT_R, - (80, 65), - ), - ("conf=0.95 ✓", 14, "#A5D6A7", FONT_B, (100, 340)), - ("0.90 ✗ IoU>0.5", 13, "#EF9A9A", FONT_R, (100, 365)), - ("0.85 ✗ IoU>0.5", 13, "#EF9A9A", FONT_R, (100, 390)), - ("0.40 ✓ INNY obiekt", 13, "#64B5F6", FONT_R, (100, 420)), - ("IoU = Intersection over Union", 18, "#FFE082", FONT_B, (700, 160)), - ("IoU = pole(∩) / pole(AUB)", 16, "white", FONT_R, (700, 380)), - ("Fioletowy = intersection", 14, "#CE93D8", FONT_R, (700, 410)), - ("IoU > 0.5 → TEN SAM obiekt → usuń", 14, "#EF9A9A", FONT_R, (700, 440)), - ("IoU < 0.5 → INNY obiekt → zachowaj", 14, "#A5D6A7", FONT_R, (700, 470)), - ( - "DETR: jedyny detektor BEZ NMS (Hungarian matching zamiast tego)", - 14, - "#78909C", - FONT_R, - (80, 620), - ), - ] - for text, fs, color, font, pos in labels: - tc = ( - _tc(text=text, font_size=fs, color=color, font=font) - .with_duration(STEP_DUR) - .with_position(pos) - ) - text_clips.append(tc) - - slides.append( - CompositeVideoClip(text_clips, size=(W, H)).with_effects( - [FadeIn(0.3), FadeOut(0.3)] - ) - ) - return slides - - -# ── Detector from Classifier ───────────────────────────────────── -def _detector_from_classifier() -> list[CompositeVideoClip]: - """Show 3 approaches to building a detector from a classifier.""" - slides = [] - - approaches = [ - ( - "Podejście 1: Sliding Window (NAJWOLNIEJSZE)", - [ - ("Okno przesuwa się po obrazie w wielu skalach", "#B0BEC5"), - ("Każde okno → klasyfikator (np. ResNet) → klasa + pewność", "#B0BEC5"), - ("~18 000 okien x 10ms = ~3 minuty na obraz!", "#EF9A9A"), - ("Mnemonik: WYCINAJ i PYTAJ — jak wycinanie ciasteczek", "#FFE082"), - ], - "SRF", - ), - ( - "Podejście 2: Region Proposals (= R-CNN)", - [ - ("Selective Search → ~2000 inteligentnych regionów", "#B0BEC5"), - ("Każdy region → CNN → wektor cech → SVM klasyfikuje", "#B0BEC5"), - ("~2000 x 10ms = ~20 sec — 9x szybciej!", "#64B5F6"), - ( - "Mnemonik: INTELIGENTNE CIĘCIE — wytnij tylko tam gdzie wiśnie", - "#FFE082", - ), - ], - "SRF", - ), - ( - "Podejście 3: Fine-tune backbone (NAJLEPSZE)", - [ - ( - "Pretrained backbone (ResNet) → odetnij FC → dodaj detection head", - "#B0BEC5", - ), - ( - "Detection head = głowica klasyfikacji + głowica regresji bbox", - "#B0BEC5", - ), - ("~0.2 sec/obraz, najlepsza jakość (mAP ~42%)", "#A5D6A7"), - ("Mnemonik: PRZESZCZEP GŁOWY — ten sam silnik, nowa głowa", "#FFE082"), - ], - "SRF", - ), - ] - - for title, points, _mnem in approaches: - lines = [ - (title, 24, "#FFE082", FONT_B, (80, 140)), - ] - for i, (text, color) in enumerate(points): - lines.append((f"• {text}", 18, color, FONT_R, (100, 220 + i * 50))) - - lines.append( - ( - "Detektor z klasyfikatora: SRF = Sliding → Region → Fine-tune", - 16, - "#78909C", - FONT_R, - (80, 520), - ) - ) - lines.append( - ( - "= Szukaj Ręcznie, Finalnie optymalizuj!", - 16, - "#90CAF9", - FONT_R, - (80, 550), - ) - ) - - slides.append(_text_slide(lines, duration=STEP_DUR)) - - return slides - - -def _text_slide( - lines: list[tuple[str, int, str, str, tuple[str | int, str | int]]], - duration: float = STEP_DUR, -) -> CompositeVideoClip: - bg = ColorClip(size=(W, H), color=BG_COLOR).with_duration(duration) - clips: list[VideoClip] = [bg] - for text, font_size, color, font, pos in lines: - tc = ( - _tc( - text=text, - font_size=font_size, - color=color, - font=font, - ) - .with_duration(duration) - .with_position(pos) - ) - clips.append(tc) - return CompositeVideoClip(clips, size=(W, H)).with_effects( - [FadeIn(0.3), FadeOut(0.3)] - ) - - -# ── Methods comparison ──────────────────────────────────────────── -def _methods_comparison() -> CompositeVideoClip: - bg = ColorClip(size=(W, H), color=BG_COLOR).with_duration(10.0) - title = ( - _tc( - text="Porównanie detektorów", - font_size=36, - color="white", - font=FONT_B, - ) - .with_duration(10.0) - .with_position(("center", 20)) - ) - - rows = [ - ("Model", "Rok", "Typ", "Szybkość", "Kluczowe"), - ("HOG+SVM", "2005", "Klasyczny", "~1 fps", "Gradient histogramy"), - ("Viola-Jones", "2001", "Klasyczny", "30+ fps", "Haar+Cascade"), - ("R-CNN", "2014", "Two-stage", "50 sec!", "CNN per region"), - ("Fast R-CNN", "2015", "Two-stage", "2 sec", "ROI Pooling"), - ("Faster R-CNN", "2015", "Two-stage", "5 fps", "RPN w sieci"), - ("YOLO", "2016", "One-stage", "45+ fps", "Siatka SxS"), - ("DETR", "2020", "Transformer", "~40 fps", "Bez NMS!"), - ] - - clips: list[VideoClip] = [bg, title] - for i, row in enumerate(rows): - y_pos = 75 + i * 72 - col_x = [40, 200, 280, 400, 530] - for j, cell in enumerate(row): - fs = 16 if i > 0 else 18 - color = "#64B5F6" if i == 0 else "#E0E0E0" - tc = ( - _tc( - text=cell, - font_size=fs, - color=color, - font=FONT_B if i == 0 else FONT_R, - ) - .with_duration(10.0) - .with_position((col_x[j], y_pos)) - ) - clips.append(tc) - - return CompositeVideoClip(clips, size=(W, H)).with_effects( - [FadeIn(0.5), FadeOut(0.5)] - ) +from _q24_common import FPS, OUTPUT, _logger, _make_header +from _q24_nms_final import ( + _detector_from_classifier, + _methods_comparison, + _nms_iou_demo, +) +from _q24_rcnn import ( + _rcnn_detailed, + _rcnn_evolution, + _roi_pooling_demo, +) +from _q24_rpn_yolo import _rpn_anchors_demo, _yolo_demo +from _q24_yolo_arch_detr import _detr_demo, _yolo_architecture +from moviepy import VideoClip, concatenate_videoclips # ── Main ────────────────────────────────────────────────────────── @@ -1923,4 +138,5 @@ def main() -> None: if __name__ == "__main__": + logging.basicConfig(level=logging.INFO) main()