diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..ee7b7fa --- /dev/null +++ b/.editorconfig @@ -0,0 +1,387 @@ +root = true + +# All files +[*] +indent_style = space + +# Xml files +[*.xml] +indent_size = 2 + +# C# files +[*.cs] + +#### Core EditorConfig Options #### + +# Indentation and spacing +indent_size = 2 +tab_width = 2 + +# New line preferences +insert_final_newline = false + +#### .NET Coding Conventions #### +[*.{cs,vb}] + +# Organize usings +dotnet_separate_import_directive_groups = true +dotnet_sort_system_directives_first = true +file_header_template = unset + +# this. and Me. preferences +dotnet_style_qualification_for_event = false:silent +dotnet_style_qualification_for_field = false:silent +dotnet_style_qualification_for_method = false:silent +dotnet_style_qualification_for_property = false:silent + +# Language keywords vs BCL types preferences +dotnet_style_predefined_type_for_locals_parameters_members = true:silent +dotnet_style_predefined_type_for_member_access = true:silent + +# Parentheses preferences +dotnet_style_parentheses_in_arithmetic_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_binary_operators = always_for_clarity:silent +dotnet_style_parentheses_in_other_operators = never_if_unnecessary:silent +dotnet_style_parentheses_in_relational_binary_operators = always_for_clarity:silent + +# Modifier preferences +dotnet_style_require_accessibility_modifiers = for_non_interface_members:silent + +# Expression-level preferences +dotnet_style_coalesce_expression = true:suggestion +dotnet_style_collection_initializer = true:suggestion +dotnet_style_explicit_tuple_names = true:suggestion +dotnet_style_namespace_match_folder = true:suggestion +dotnet_style_null_propagation = true:suggestion +dotnet_style_object_initializer = true:suggestion +dotnet_style_operator_placement_when_wrapping = beginning_of_line +dotnet_style_prefer_auto_properties = true:suggestion +dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion +dotnet_style_prefer_compound_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_assignment = true:suggestion +dotnet_style_prefer_conditional_expression_over_return = true:suggestion +dotnet_style_prefer_foreach_explicit_cast_in_source = when_strongly_typed:suggestion +dotnet_style_prefer_inferred_anonymous_type_member_names = true:suggestion +dotnet_style_prefer_inferred_tuple_names = true:suggestion +dotnet_style_prefer_is_null_check_over_reference_equality_method = true:suggestion +dotnet_style_prefer_simplified_boolean_expressions = true:suggestion +dotnet_style_prefer_simplified_interpolation = true:suggestion + +# Field preferences +dotnet_style_readonly_field = true:warning + +# Parameter preferences +dotnet_code_quality_unused_parameters = all:suggestion + +# Suppression preferences +dotnet_remove_unnecessary_suppression_exclusions = none + +#### C# Coding Conventions #### +[*.cs] + +# diagnostics +dotnet_diagnostic.CA1303.severity = none +dotnet_diagnostic.CA1707.severity = none +dotnet_diagnostic.IDE0058.severity = none +dotnet_diagnostic.CA2007.severity = none +dotnet_diagnostic.CA1515.severity = none +dotnet_diagnostic.IDE0100.severity = none +dotnet_diagnostic.IDE0046.severity = none + +# var preferences +csharp_style_var_elsewhere = true:suggestion +csharp_style_var_for_built_in_types = true:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion + +# Expression-bodied members +csharp_style_expression_bodied_accessors = true:silent +csharp_style_expression_bodied_constructors = false:silent +csharp_style_expression_bodied_indexers = true:silent +csharp_style_expression_bodied_lambdas = true:suggestion +csharp_style_expression_bodied_local_functions = false:silent +csharp_style_expression_bodied_methods = false:silent +csharp_style_expression_bodied_operators = false:silent +csharp_style_expression_bodied_properties = true:silent + +# Pattern matching preferences +csharp_style_pattern_matching_over_as_with_null_check = true:suggestion +csharp_style_pattern_matching_over_is_with_cast_check = true:suggestion +csharp_style_prefer_extended_property_pattern = true:suggestion +csharp_style_prefer_not_pattern = true:suggestion +csharp_style_prefer_pattern_matching = true:silent +csharp_style_prefer_switch_expression = true:suggestion + +# Null-checking preferences +csharp_style_conditional_delegate_call = true:suggestion + +# Modifier preferences +csharp_prefer_static_anonymous_function = true:suggestion +csharp_prefer_static_local_function = true:warning +csharp_preferred_modifier_order = public,private,protected,internal,file,const,static,extern,new,virtual,abstract,sealed,override,readonly,unsafe,required,volatile,async:suggestion +csharp_style_prefer_readonly_struct = true:suggestion +csharp_style_prefer_readonly_struct_member = true:suggestion + +# Code-block preferences +csharp_prefer_braces = true:silent +csharp_prefer_simple_using_statement = true:suggestion +csharp_style_namespace_declarations = file_scoped:suggestion +csharp_style_prefer_method_group_conversion = true:silent +csharp_style_prefer_primary_constructors = true:suggestion +csharp_style_prefer_top_level_statements = true:silent + +# Expression-level preferences +csharp_prefer_simple_default_expression = true:suggestion +csharp_style_deconstructed_variable_declaration = true:suggestion +csharp_style_implicit_object_creation_when_type_is_apparent = true:suggestion +csharp_style_inlined_variable_declaration = true:suggestion +csharp_style_prefer_index_operator = true:suggestion +csharp_style_prefer_local_over_anonymous_function = true:suggestion +csharp_style_prefer_null_check_over_type_check = true:suggestion +csharp_style_prefer_range_operator = true:suggestion +csharp_style_prefer_tuple_swap = true:suggestion +csharp_style_prefer_utf8_string_literals = true:suggestion +csharp_style_throw_expression = true:suggestion +csharp_style_unused_value_assignment_preference = discard_variable:silent +csharp_style_unused_value_expression_statement_preference = discard_variable:silent + +# 'using' directive preferences +csharp_using_directive_placement = outside_namespace:silent + +#### C# Formatting Rules #### + +# New line preferences +csharp_new_line_before_catch = true +csharp_new_line_before_else = true +csharp_new_line_before_finally = true +csharp_new_line_before_members_in_anonymous_types = true +csharp_new_line_before_members_in_object_initializers = true +csharp_new_line_before_open_brace = all +csharp_new_line_between_query_expression_clauses = true + +# Indentation preferences +csharp_indent_block_contents = true +csharp_indent_braces = false +csharp_indent_case_contents = true +csharp_indent_case_contents_when_block = true +csharp_indent_labels = one_less_than_current +csharp_indent_switch_labels = true + +# Space preferences +csharp_space_after_cast = false +csharp_space_after_colon_in_inheritance_clause = true +csharp_space_after_comma = true +csharp_space_after_dot = false +csharp_space_after_keywords_in_control_flow_statements = true +csharp_space_after_semicolon_in_for_statement = true +csharp_space_around_binary_operators = before_and_after +csharp_space_around_declaration_statements = false +csharp_space_before_colon_in_inheritance_clause = true +csharp_space_before_comma = false +csharp_space_before_dot = false +csharp_space_before_open_square_brackets = false +csharp_space_before_semicolon_in_for_statement = false +csharp_space_between_empty_square_brackets = false +csharp_space_between_method_call_empty_parameter_list_parentheses = false +csharp_space_between_method_call_name_and_opening_parenthesis = false +csharp_space_between_method_call_parameter_list_parentheses = false +csharp_space_between_method_declaration_empty_parameter_list_parentheses = false +csharp_space_between_method_declaration_name_and_open_parenthesis = false +csharp_space_between_method_declaration_parameter_list_parentheses = false +csharp_space_between_parentheses = false +csharp_space_between_square_brackets = false + +# Wrapping preferences +csharp_preserve_single_line_blocks = true +csharp_preserve_single_line_statements = true + +#### Naming styles #### +[*.{cs,vb}] + +# Naming rules + +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.symbols = types_and_namespaces +dotnet_naming_rule.types_and_namespaces_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.interfaces_should_be_ipascalcase.severity = suggestion +dotnet_naming_rule.interfaces_should_be_ipascalcase.symbols = interfaces +dotnet_naming_rule.interfaces_should_be_ipascalcase.style = ipascalcase + +dotnet_naming_rule.type_parameters_should_be_tpascalcase.severity = suggestion +dotnet_naming_rule.type_parameters_should_be_tpascalcase.symbols = type_parameters +dotnet_naming_rule.type_parameters_should_be_tpascalcase.style = tpascalcase + +dotnet_naming_rule.methods_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.methods_should_be_pascalcase.symbols = methods +dotnet_naming_rule.methods_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.properties_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.properties_should_be_pascalcase.symbols = properties +dotnet_naming_rule.properties_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.events_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.events_should_be_pascalcase.symbols = events +dotnet_naming_rule.events_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.local_variables_should_be_camelcase.severity = suggestion +dotnet_naming_rule.local_variables_should_be_camelcase.symbols = local_variables +dotnet_naming_rule.local_variables_should_be_camelcase.style = camelcase + +dotnet_naming_rule.local_constants_should_be_camelcase.severity = suggestion +dotnet_naming_rule.local_constants_should_be_camelcase.symbols = local_constants +dotnet_naming_rule.local_constants_should_be_camelcase.style = camelcase + +dotnet_naming_rule.parameters_should_be_camelcase.severity = suggestion +dotnet_naming_rule.parameters_should_be_camelcase.symbols = parameters +dotnet_naming_rule.parameters_should_be_camelcase.style = camelcase + +dotnet_naming_rule.public_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_fields_should_be_pascalcase.symbols = public_fields +dotnet_naming_rule.public_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_fields_should_be__camelcase.severity = suggestion +dotnet_naming_rule.private_fields_should_be__camelcase.symbols = private_fields +dotnet_naming_rule.private_fields_should_be__camelcase.style = _camelcase + +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.severity = suggestion +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.symbols = private_static_fields +dotnet_naming_rule.private_static_fields_should_be_s_camelcase.style = s_camelcase + +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.symbols = public_constant_fields +dotnet_naming_rule.public_constant_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.symbols = private_constant_fields +dotnet_naming_rule.private_constant_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.symbols = public_static_readonly_fields +dotnet_naming_rule.public_static_readonly_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.symbols = private_static_readonly_fields +dotnet_naming_rule.private_static_readonly_fields_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.enums_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.enums_should_be_pascalcase.symbols = enums +dotnet_naming_rule.enums_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.local_functions_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.local_functions_should_be_pascalcase.symbols = local_functions +dotnet_naming_rule.local_functions_should_be_pascalcase.style = pascalcase + +dotnet_naming_rule.non_field_members_should_be_pascalcase.severity = suggestion +dotnet_naming_rule.non_field_members_should_be_pascalcase.symbols = non_field_members +dotnet_naming_rule.non_field_members_should_be_pascalcase.style = pascalcase + +# Symbol specifications + +dotnet_naming_symbols.interfaces.applicable_kinds = interface +dotnet_naming_symbols.interfaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.interfaces.required_modifiers = + +dotnet_naming_symbols.enums.applicable_kinds = enum +dotnet_naming_symbols.enums.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.enums.required_modifiers = + +dotnet_naming_symbols.events.applicable_kinds = event +dotnet_naming_symbols.events.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.events.required_modifiers = + +dotnet_naming_symbols.methods.applicable_kinds = method +dotnet_naming_symbols.methods.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.methods.required_modifiers = + +dotnet_naming_symbols.properties.applicable_kinds = property +dotnet_naming_symbols.properties.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.properties.required_modifiers = + +dotnet_naming_symbols.public_fields.applicable_kinds = field +dotnet_naming_symbols.public_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_fields.required_modifiers = + +dotnet_naming_symbols.private_fields.applicable_kinds = field +dotnet_naming_symbols.private_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_fields.required_modifiers = + +dotnet_naming_symbols.private_static_fields.applicable_kinds = field +dotnet_naming_symbols.private_static_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_static_fields.required_modifiers = static + +dotnet_naming_symbols.types_and_namespaces.applicable_kinds = namespace, class, struct, interface, enum +dotnet_naming_symbols.types_and_namespaces.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.types_and_namespaces.required_modifiers = + +dotnet_naming_symbols.non_field_members.applicable_kinds = property, event, method +dotnet_naming_symbols.non_field_members.applicable_accessibilities = public, internal, private, protected, protected_internal, private_protected +dotnet_naming_symbols.non_field_members.required_modifiers = + +dotnet_naming_symbols.type_parameters.applicable_kinds = namespace +dotnet_naming_symbols.type_parameters.applicable_accessibilities = * +dotnet_naming_symbols.type_parameters.required_modifiers = + +dotnet_naming_symbols.private_constant_fields.applicable_kinds = field +dotnet_naming_symbols.private_constant_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_constant_fields.required_modifiers = const + +dotnet_naming_symbols.local_variables.applicable_kinds = local +dotnet_naming_symbols.local_variables.applicable_accessibilities = local +dotnet_naming_symbols.local_variables.required_modifiers = + +dotnet_naming_symbols.local_constants.applicable_kinds = local +dotnet_naming_symbols.local_constants.applicable_accessibilities = local +dotnet_naming_symbols.local_constants.required_modifiers = const + +dotnet_naming_symbols.parameters.applicable_kinds = parameter +dotnet_naming_symbols.parameters.applicable_accessibilities = * +dotnet_naming_symbols.parameters.required_modifiers = + +dotnet_naming_symbols.public_constant_fields.applicable_kinds = field +dotnet_naming_symbols.public_constant_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_constant_fields.required_modifiers = const + +dotnet_naming_symbols.public_static_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.public_static_readonly_fields.applicable_accessibilities = public, internal +dotnet_naming_symbols.public_static_readonly_fields.required_modifiers = readonly, static + +dotnet_naming_symbols.private_static_readonly_fields.applicable_kinds = field +dotnet_naming_symbols.private_static_readonly_fields.applicable_accessibilities = private, protected, protected_internal, private_protected +dotnet_naming_symbols.private_static_readonly_fields.required_modifiers = readonly, static + +dotnet_naming_symbols.local_functions.applicable_kinds = local_function +dotnet_naming_symbols.local_functions.applicable_accessibilities = * +dotnet_naming_symbols.local_functions.required_modifiers = + +# Naming styles + +dotnet_naming_style.pascalcase.required_prefix = +dotnet_naming_style.pascalcase.required_suffix = +dotnet_naming_style.pascalcase.word_separator = +dotnet_naming_style.pascalcase.capitalization = pascal_case + +dotnet_naming_style.ipascalcase.required_prefix = I +dotnet_naming_style.ipascalcase.required_suffix = +dotnet_naming_style.ipascalcase.word_separator = +dotnet_naming_style.ipascalcase.capitalization = pascal_case + +dotnet_naming_style.tpascalcase.required_prefix = T +dotnet_naming_style.tpascalcase.required_suffix = +dotnet_naming_style.tpascalcase.word_separator = +dotnet_naming_style.tpascalcase.capitalization = pascal_case + +dotnet_naming_style._camelcase.required_prefix = _ +dotnet_naming_style._camelcase.required_suffix = +dotnet_naming_style._camelcase.word_separator = +dotnet_naming_style._camelcase.capitalization = camel_case + +dotnet_naming_style.camelcase.required_prefix = +dotnet_naming_style.camelcase.required_suffix = +dotnet_naming_style.camelcase.word_separator = +dotnet_naming_style.camelcase.capitalization = camel_case + +dotnet_naming_style.s_camelcase.required_prefix = s_ +dotnet_naming_style.s_camelcase.required_suffix = +dotnet_naming_style.s_camelcase.word_separator = +dotnet_naming_style.s_camelcase.capitalization = camel_case + diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml new file mode 100644 index 0000000..42a6fc3 --- /dev/null +++ b/.github/workflows/publish.yml @@ -0,0 +1,103 @@ +name: publish_and_release +on: + workflow_dispatch: + push: + paths-ignore: + - "**/README.md" + - "**/.gitignore" + - "**/.gitaltributes" + - "**/.editorconfig" + - "**/LICENSE.txt" + - .github/** + - src/BGR.Console.Tests/** + branches: + - main +jobs: + version: + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + lfs: true + fetch-depth: 0 + token: ${{ secrets.ACTIONS_PAT }} + - name: Setup .NET 9 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.x + - name: Install versionize + run: dotnet tool install --global Versionize + - name: Setup git + run: | + git config --local user.email "65925598+StevanFreeborn@users.noreply.github.com" + git config --local user.name "Stevan Freeborn" + - name: Run versionize + id: versionize + run: versionize -i --exit-insignificant-commits --workingDir ./src/BGR.Console --commit-suffix "[skip ci]" + continue-on-error: true + - name: Upload changelog + if: steps.versionize.outcome == 'success' + uses: actions/upload-artifact@v4 + with: + name: change-log + path: src/BGR.Console/CHANGELOG.md + - name: Push changes to GitHub + if: steps.versionize.outcome == 'success' + uses: ad-m/github-push-action@master + with: + github_token: ${{ secrets.ACTIONS_PAT }} + branch: ${{ github.ref }} + tags: true + outputs: + is_new_version: ${{ steps.versionize.outcome == 'success' }} + publish: + needs: [version] + if: needs.version.outputs.is_new_version == 'true' + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + lfs: true + fetch-depth: 0 + ref: ${{ github.ref }} + token: ${{ secrets.ACTIONS_PAT }} + - name: Setup .NET 9 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.x + - name: Publish for mac-os + run: dotnet publish src/BGR.Console/BGR.Console.csproj -c Release -r osx-x64 --self-contained -o dist/mac-os + - name: Rename mac-os binary + run: mv dist/mac-os/BGR.Console dist/bgr_osx_x64 + - name: Publish for linux-os + run: dotnet publish src/BGR.Console/BGR.Console.csproj -c Release -r linux-x64 --self-contained -o dist/linux-os + - name: Rename linux-os binary + run: mv dist/linux-os/BGR.Console dist/bgr_linux_x64 + - name: Publish for windows-os + run: dotnet publish src/BGR.Console/BGR.Console.csproj -c Release -r win-x64 --self-contained -o dist/windows-os + - name: Rename windows-os binary + run: mv dist/windows-os/BGR.Console.exe dist/bgr_windows_x64.exe + - name: Get project version + uses: kzrnm/get-net-sdk-project-versions-action@v1 + id: get-version + with: + proj-path: src/BGR.Console/BGR.Console.csproj + - name: Download changlog + uses: actions/download-artifact@v4 + with: + name: change-log + path: src/BGR.Console + - name: Create release + uses: softprops/action-gh-release@v1 + with: + token: ${{ secrets.ACTIONS_PAT }} + name: bgr v${{ steps.get-version.outputs.version }} + tag_name: v${{ steps.get-version.outputs.version }} + draft: false + body_path: src/BGR.Console/CHANGELOG.md + files: | + dist/bgr_osx_x64 + dist/bgr_linux_x64 + dist/bgr_windows_x64.exe diff --git a/.github/workflows/pull_request.yml b/.github/workflows/pull_request.yml new file mode 100644 index 0000000..022ebfa --- /dev/null +++ b/.github/workflows/pull_request.yml @@ -0,0 +1,107 @@ +name: Pull Request +on: + workflow_dispatch: + pull_request: + paths-ignore: + - "**/README.md" + - "**/LICENSE.txt" + - "**/.gitignore" + - "**/.gitattributes" + - "**/.editorconfig" + - .github/** + branches: + - main +jobs: + format: + name: Run dotnet format + runs-on: ubuntu-latest + permissions: + contents: write + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + lfs: true + fetch-depth: 0 + token: ${{ secrets.ACTIONS_PAT }} + - name: Setup .NET + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.x.x + - name: Format project + run: dotnet format src --verbosity normal + - name: Commit Changes + run: | + git config user.name "GitHub Actions" + git config user.email "<>" + if [[ $(git status --porcelain) ]]; then + git add . + git commit -m "chore: format fixes [skip ci]" + git fetch origin + git pull --rebase origin ${{ github.head_ref }} + git push origin HEAD:${{ github.head_ref }} + fi + test: + name: Test + needs: format + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + runs-on: ${{ matrix.os }} + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + lfs: true + fetch-depth: 0 + token: ${{ secrets.ACTIONS_PAT }} + - name: Setup .NET 9 + uses: actions/setup-dotnet@v4 + with: + dotnet-version: 9.x + - name: Install report generator + run: dotnet tool install --global dotnet-reportgenerator-globaltool + - name: Restore dependencies + run: dotnet restore src + - name: Build on ${{ matrix.os }} + run: dotnet build src --no-restore + - name: Test on ${{ matrix.os }} + run: dotnet test src --no-build --verbosity normal + - name: Rename test coverage report + run: mv src/BGR.Console.Tests/TestResults/Coverage/coverage.cobertura.xml src/BGR.Console.Tests/TestResults/Coverage/${{ matrix.os }}-coverage.cobertura.xml + - name: Upload test coverage report for ${{ matrix.os }} + uses: actions/upload-artifact@v4 + with: + name: test-coverage-${{ matrix.os }} + path: src/BGR.Console.Tests/TestResults/Coverage/${{ matrix.os }}-coverage.cobertura.xml + upload_test_coverage: + name: Upload Test Coverage + needs: test + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + with: + lfs: true + fetch-depth: 0 + token: ${{ secrets.ACTIONS_PAT }} + - name: Download ubuntu-latest report + uses: actions/download-artifact@v4 + with: + name: test-coverage-ubuntu-latest + path: ./coverage + - name: Download windows-latest report + uses: actions/download-artifact@v4 + with: + name: test-coverage-windows-latest + path: ./coverage + - name: Download macos-latest report + uses: actions/download-artifact@v4 + with: + name: test-coverage-macos-latest + path: ./coverage + - name: Upload test coverage reports + uses: codecov/codecov-action@v4 + with: + token: ${{ secrets.CODECOV_TOKEN }} diff --git a/BGR.Console/BGR.Console.csproj b/BGR.Console/BGR.Console.csproj deleted file mode 100644 index 9e8ff66..0000000 --- a/BGR.Console/BGR.Console.csproj +++ /dev/null @@ -1,21 +0,0 @@ - - - - Exe - net9.0 - enable - enable - - - - - - - - - - - - - - diff --git a/BGR.Console/Program.cs b/BGR.Console/Program.cs deleted file mode 100644 index 4c6f8c2..0000000 --- a/BGR.Console/Program.cs +++ /dev/null @@ -1,207 +0,0 @@ -using System.Reflection; - -using Microsoft.ML.OnnxRuntime; -using Microsoft.ML.OnnxRuntime.Tensors; - -using SixLabors.ImageSharp; -using SixLabors.ImageSharp.Formats.Png; -using SixLabors.ImageSharp.PixelFormats; -using SixLabors.ImageSharp.Processing; - -if (args.Length < 1) -{ - Console.WriteLine("Usage: BackgroundRemover "); - return; -} - -var inputImagePath = args[0]; -var maskImagePath = Path.ChangeExtension(inputImagePath, null) + "_mask.png"; -var outputImagePath = Path.ChangeExtension(inputImagePath, null) + "_no_bg.png"; - -try -{ - var assembly = Assembly.GetExecutingAssembly(); - var resourceName = "BGR.Console.u2net.onnx"; - - using var stream = assembly.GetManifestResourceStream(resourceName) ?? throw new FileNotFoundException("Model not found in embedded resources."); - var modelBytes = new byte[stream.Length]; - stream.ReadExactly(modelBytes); - - using var image = Image.Load(inputImagePath); - var inputTensor = CreateTensorInput(image); - - using var options = new SessionOptions() { LogSeverityLevel = OrtLoggingLevel.ORT_LOGGING_LEVEL_ERROR }; - using InferenceSession session = new(modelBytes, options); - var inputs = new List() - { - NamedOnnxValue.CreateFromTensor(session.InputNames[0], inputTensor), - }; - - using var results = session.Run(inputs); - var outputTensor = results[0].AsTensor(); - - using var mask = GenerateMask(outputTensor, image.Width, image.Height); - - using var bgRemoved = GetImageWithBackgroundRemoved(image, mask); - - var encoder = new PngEncoder { CompressionLevel = PngCompressionLevel.BestCompression }; - - mask.Save(maskImagePath, encoder); - bgRemoved.Save(outputImagePath, encoder); - - Console.WriteLine($"Background removed and saved to {outputImagePath}"); -} -catch (Exception ex) -{ - Console.WriteLine($"Error: {ex.Message}"); - throw; -} - -static DenseTensor CreateTensorInput(Image image) -{ - // U2Net expects input images to be 320x320. This is dependent on the model. - const int targetWidth = 320; - const int targetHeight = 320; - - // ImageNet normalization parameters - // source: - // - https://www.image-net.org/ - // - https://pytorch.org calculated these values from the ImageNet dataset - // and they are commonly used for models trained on ImageNet so we use them here - // to normalize the input image to better match the distribution of the data the model was trained on - // NOTE: These values are not universal and may vary for different models - const float rMean = 0.485f; // Mean value for Red channel - const float gMean = 0.456f; // Mean value for Green channel - const float bMean = 0.406f; // Mean value for Blue channel - const float rStd = 0.229f; // Standard deviation for Red channel - const float gStd = 0.224f; // Standard deviation for Green channel - const float bStd = 0.225f; // Standard deviation for Blue channel - const float pixelMax = 255f; // Maximum pixel intensity for normalization - - // Create a temporary image for preprocessing - using var resized = image.Clone(); - resized.Mutate(x => x.Resize(targetWidth, targetHeight)); - - // Create tensor of shape (1, 3, 320, 320) - // 1 for batch size, 3 for RGB channels, 320x320 for image dimensions - DenseTensor tensor = new([1, 3, targetHeight, targetWidth]); - - // Normalize pixel values and copy to tensor - WalkImage(resized.Height, resized.Width, (x, y) => - { - var pixel = resized[x, y]; - - // u2net expects expect input images to be normalized using ImageNet mean and std - // to better match the distribution of the data the model was trained on - // Normalize to range [0, 1] and standardize using ImageNet mean/std - // The tensor is filled with normalized pixel values - tensor[0, 0, y, x] = ((pixel.R / pixelMax) - rMean) / rStd; // Red channel - tensor[0, 1, y, x] = ((pixel.G / pixelMax) - gMean) / gStd; // Green channel - tensor[0, 2, y, x] = ((pixel.B / pixelMax) - bMean) / bStd; // Blue channel - }); - - return tensor; -} - -static Image GenerateMask(Tensor maskTensor, int width, int height) -{ - var mask = new Image(width, height); - - var sourceHeight = maskTensor.Dimensions[2]; // Height of the original tensor mask - var sourceWidth = maskTensor.Dimensions[3]; // Width of the original tensor mask - - using Image tempMask = new(sourceWidth, sourceHeight); - - // Sigmoid function parameters - const float sigmoidScale = 1f; // Scaling factor for sigmoid activation - const float sigmoidShift = 1f; // Shift factor in the denominator of the sigmoid function - const float sigmoidDivisor = -1f; // Multiplier for the exponent in the sigmoid function - - static float CalculateSigmoid(float x) - { - return sigmoidScale / (sigmoidShift + MathF.Exp(sigmoidDivisor * x)); - } - - const float binarizationThreshold = 0.5f; // Threshold to determine foreground vs. background - const float normalizationFactor = 2f; // Scales the thresholded value to enhance contrast - - // Pixel intensity values - const byte maxIntensity = 255; // Maximum grayscale intensity - const byte opaqueAlpha = 255; // Fully opaque alpha value - - - WalkImage(sourceHeight, sourceWidth, (x, y) => - { - // a sigmoid function is a function that produces an S-shaped curve - // it is often used in machine learning and statistics to model probabilities - // the sigmoid function is defined as: - // f(x) = 1 / (1 + e^(-x)) - // where e is the base of the natural logarithm and x is the input value - - // the raw tensor values for our mask are going to be real unbounded numbers - // i.e. -1.5, 0.5, 2.0, etc. - // the sigmoid function will map these values to a range between 0 and 1 - // this allows us to say that value closer to 0 is background and value - // closer to 1 is foreground - var sigmoidValue = CalculateSigmoid(maskTensor[0, 0, y, x]); - - // now we want to threshold the sigmoid value to determine if it is foreground or background - // we are arbitrarily choosing 0.5 as the threshold. so if the sigmoid value is greater than - // 0.5 we will consider it foreground and if it is less than 0.5 we will consider it background - - // when a sigmoid value is greater than 0.5 we will subtract the threshold from it - // and multiply it by 2 this way the intensity value will be larger for values closer to 1 - // and create more contrast in the mask - var normalizedValue = sigmoidValue > binarizationThreshold - ? (sigmoidValue - binarizationThreshold) * normalizationFactor - : 0f; - - // Convert to an 8-bit grayscale intensity - var intensity = (byte)(normalizedValue * maxIntensity); - - // Store the pixel with full opacity - tempMask[x, y] = new Rgba32(intensity, intensity, intensity, opaqueAlpha); - }); - - // Resize the mask to match the target dimensions - tempMask.Mutate(x => x.Resize(width, height)); - - // Copy the resized mask to the final output image - WalkImage(height, width, (x, y) => mask[x, y] = tempMask[x, y]); - - return mask; -} - -static Image GetImageWithBackgroundRemoved(Image image, Image mask) -{ - Image result = new(image.Width, image.Height); - - const byte alphaThreshold = 20; - Rgba32 transparentPixel = new(0, 0, 0, 0); - - WalkImage(image.Height, image.Width, (x, y) => - { - var sourcePixel = image[x, y]; - var maskPixel = mask[x, y]; - - var alpha = maskPixel.R; - - result[x, y] = alpha > alphaThreshold - ? new Rgba32(sourcePixel.R, sourcePixel.G, sourcePixel.B, sourcePixel.A) - : transparentPixel; - }); - - return result; -} - -static void WalkImage(int height, int width, Action action) -{ - for (var y = 0; y < height; y++) - { - for (var x = 0; x < width; x++) - { - action(x, y); - } - } -} - diff --git a/README.md b/README.md new file mode 100644 index 0000000..bca113e --- /dev/null +++ b/README.md @@ -0,0 +1,35 @@ +# BGR + +This is a background removal app that uses machine learning models to remove the background from an image. The app is available for download as a single file executable [here](). The app can also be built from source using the [.NET SDK](https://dotnet.microsoft.com/download). If you'd like to read more about the idea and initial development behind the app you can read the blog post I wrote about it [here](). + +## Usage + +The app is meant to be run from the command line. For example: + +```pwsh +bgr /path/to/image.jpg --output /path/to/output.jpg +``` + +You can find the full list of commands, arguments, and options using the `--help` option: + +```pwsh +bgr --help +``` + +### Examples + +#### Input Image + +![Input Image](examples/input.png) + +#### Output Image + +![Output Image](examples/output.png) + +## Issues + +If you encounter any issues while using the app, please open an issue on the repository. If you have any suggestions or feature requests, feel free to open an issue as well. + +## Contributing + +If you'd like to contribute to the project, feel free to fork the repository and submit a pull request. If you have any questions or suggestions, feel free to open an issue. diff --git a/examples/input.png b/examples/input.png new file mode 100644 index 0000000..29e88e2 Binary files /dev/null and b/examples/input.png differ diff --git a/examples/output.png b/examples/output.png new file mode 100644 index 0000000..0af1deb Binary files /dev/null and b/examples/output.png differ diff --git a/src/BGR.Console.Tests/BGR.Console.Tests.csproj b/src/BGR.Console.Tests/BGR.Console.Tests.csproj new file mode 100644 index 0000000..34e37a0 --- /dev/null +++ b/src/BGR.Console.Tests/BGR.Console.Tests.csproj @@ -0,0 +1,52 @@ + + + + false + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + + + true + ./TestResults/Coverage/ + cobertura + [BGR.Console]* + **/Program.cs + + + + + + + + + + + + + + + + diff --git a/src/BGR.Console.Tests/Integration/AppFactory.cs b/src/BGR.Console.Tests/Integration/AppFactory.cs new file mode 100644 index 0000000..91111d4 --- /dev/null +++ b/src/BGR.Console.Tests/Integration/AppFactory.cs @@ -0,0 +1,19 @@ +namespace BGR.Console.Tests.Integration; + +internal static class AppFactory +{ + public static CommandApp Create() + { + return Host.CreateDefaultBuilder() + .ConfigureLogging(static logging => logging.ClearProviders()) + .ConfigureServices(static (_, services) => + { + services.AddSingleton(new TestConsole()); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + }) + .BuildApp(); + } +} \ No newline at end of file diff --git a/src/BGR.Console.Tests/Integration/RemovalCommandTests.cs b/src/BGR.Console.Tests/Integration/RemovalCommandTests.cs new file mode 100644 index 0000000..e14f24a --- /dev/null +++ b/src/BGR.Console.Tests/Integration/RemovalCommandTests.cs @@ -0,0 +1,24 @@ +namespace BGR.Console.Tests.Integration; + +public class RemovalCommandTests +{ + private readonly CommandApp _app = AppFactory.Create(); + + [Fact] + public async Task RunAsync_WhenCalled_ItShouldRemoveImageBackground() + { + var imagePath = $"{Guid.NewGuid()}.png"; + var outputPath = $"{Guid.NewGuid()}.png"; + + using var testImage = new Image(100, 100); + await testImage.SaveAsPngAsync(imagePath); + + var result = await _app.RunAsync([imagePath, "--model", "u2net", "--output", outputPath]); + + result.ShouldBe(0); + File.Exists(outputPath).ShouldBeTrue(); + + File.Delete(imagePath); + File.Delete(outputPath); + } +} \ No newline at end of file diff --git a/src/BGR.Console.Tests/Unit/ImageSharpProcessorTests.cs b/src/BGR.Console.Tests/Unit/ImageSharpProcessorTests.cs new file mode 100644 index 0000000..7675f08 --- /dev/null +++ b/src/BGR.Console.Tests/Unit/ImageSharpProcessorTests.cs @@ -0,0 +1,241 @@ +namespace BGR.Console.Tests.Unit; + +public class ImageSharpProcessorTests : IDisposable +{ + private const string TestImagePath = "test.jpg"; + private bool _isDisposed; + private readonly ImageSharpProcessor _sut = new(); + private readonly Mock _modelMock = new(); + private readonly Stream _testImageStream; + + public ImageSharpProcessorTests() + { + if (File.Exists(TestImagePath) is false) + { + using var testImage = new Image(100, 100); + testImage.SaveAsJpeg(TestImagePath); + } + + _modelMock.Setup(static x => x.InputWidth).Returns(320); + _modelMock.Setup(static x => x.InputHeight).Returns(320); + + var stream = new MemoryStream(); + using var image = new Image(100, 100); + + for (var y = 0; y < image.Height; y++) + { + for (var x = 0; x < image.Width; x++) + { + image[x, y] = new Rgba32((byte)x, (byte)y, 128, 255); + } + } + + image.SaveAsPng(stream); + stream.Position = 0; + _testImageStream = stream; + } + + [Fact] + public async Task LoadImageAsync_WhenCalledWithValidPath_ItShouldReturnImage() + { + var result = await _sut.LoadImageAsync(TestImagePath); + + result.ShouldBeOfType(); + result.ShouldNotBeNull(); + result.Width.ShouldBe(100); + result.Height.ShouldBe(100); + result.Data.Length.ShouldBeGreaterThan(0); + } + + [Fact] + public async Task LoadImageAsync_WhenCalledWithValidPath_ItShouldReturnReusableStream() + { + var result = await _sut.LoadImageAsync(TestImagePath); + + result.Data.Position.ShouldBe(0); + result.Data.CanRead.ShouldBeTrue(); + + var buffer = new byte[100]; + await result.Data.ReadExactlyAsync(buffer); + + result.Data.Position = 0; + await result.Data.ReadExactlyAsync(buffer); + } + + [Fact] + public async Task CreateTensorInputAsync_WhenCalled_ItShouldResizeImageToModelDimensions() + { + const int modelWidth = 64; + const int modelHeight = 48; + + _modelMock.Setup(static x => x.InputWidth).Returns(modelWidth); + _modelMock.Setup(static x => x.InputHeight).Returns(modelHeight); + + var result = await _sut.CreateTensorInputAsync(_testImageStream, _modelMock.Object); + + result.Width.ShouldBe(modelWidth); + result.Height.ShouldBe(modelHeight); + } + + [Fact] + public async Task CreateTensorInputAsync_WhenCalled_ItShouldCreateTensorWithCorrectDimensions() + { + var result = await _sut.CreateTensorInputAsync(_testImageStream, _modelMock.Object); + + result.ShouldBeOfType(); + Should.NotThrow(() => result.GetValue(0, 2, 0, 0)); + } + + [Fact] + public async Task CreateTensorInputAsync_WhenCalled_ItShouldNormalizePixelValues() + { + var normalizedValue = 0.5f; + _modelMock.Setup(static x => x.NormalizeRed(It.IsAny())).Returns(normalizedValue); + _modelMock.Setup(static x => x.NormalizeGreen(It.IsAny())).Returns(normalizedValue); + _modelMock.Setup(static x => x.NormalizeBlue(It.IsAny())).Returns(normalizedValue); + + var result = await _sut.CreateTensorInputAsync(_testImageStream, _modelMock.Object); + + for (var y = 0; y < result.Height; y++) + { + for (var x = 0; x < result.Width; x++) + { + result.GetValue(0, 0, y, x).ShouldBe(normalizedValue); // Red + result.GetValue(0, 1, y, x).ShouldBe(normalizedValue); // Green + result.GetValue(0, 2, y, x).ShouldBe(normalizedValue); // Blue + } + } + } + + [Fact] + public async Task CreateTensorInputAsync_WhenCalled_ItShouldCallNormalizeForEachChannel() + { + await _sut.CreateTensorInputAsync(_testImageStream, _modelMock.Object); + + _modelMock.Verify(static x => x.NormalizeRed(It.IsAny()), Times.AtLeast(1)); + _modelMock.Verify(static x => x.NormalizeGreen(It.IsAny()), Times.AtLeast(1)); + _modelMock.Verify(static x => x.NormalizeBlue(It.IsAny()), Times.AtLeast(1)); + } + + [Fact] + public async Task GenerateMaskAsync_WhenCalled_ItShouldCreateMaskWithCorrectDimensions() + { + const int width = 64; + const int height = 48; + + var tensor = new OnnxTensor(1, 1, height, width); + var stream = await _sut.GenerateMaskAsync(tensor, width, height); + + using var mask = await Image.LoadAsync(stream); + + mask.Width.ShouldBe(width); + mask.Height.ShouldBe(height); + } + + [Fact] + public async Task GenerateMaskAsync_WhenCalled_ItShouldCreateGreyscaleMask() + { + var tensor = new OnnxTensor(1, 1, 100, 100); + var stream = await _sut.GenerateMaskAsync(tensor, 100, 100); + + using var mask = await Image.LoadAsync(stream); + + for (var y = 0; y < mask.Height; y++) + { + for (var x = 0; x < mask.Width; x++) + { + // we expect the mask to be greyscale + // so R, G, B should be equal + mask[x, y].R.ShouldBe(mask[x, y].G); + mask[x, y].G.ShouldBe(mask[x, y].B); + } + } + } + + [Fact] + public async Task RemoveBackgroundAsync_WithValidImageAndMask_ShouldReturnProcessedStream() + { + var width = 2; + var height = 2; + + using var imageStream = new MemoryStream(); + using var image = new Image(width, height); + + for (var x = 0; x < width; x++) + { + for (var y = 0; y < height; y++) + { + image[x, y] = new Rgba32(255, 0, 0, 255); // Red pixels + } + } + + await image.SaveAsPngAsync(imageStream); + imageStream.Position = 0; + + using var maskStream = new MemoryStream(); + using var mask = new Image(width, height); + + mask[0, 0] = new Rgba32(0, 0, 0, 255); + mask[0, 1] = new Rgba32(255, 255, 255, 255); + mask[1, 0] = new Rgba32(255, 255, 255, 255); + mask[1, 1] = new Rgba32(255, 255, 255, 255); + + await mask.SaveAsPngAsync(maskStream); + maskStream.Position = 0; + + var result = await _sut.RemoveBackgroundAsync(imageStream, maskStream); + + result.ShouldNotBeNull(); + result.Length.ShouldBeGreaterThan(0); + + result.Position = 0; + using var resultImage = await Image.LoadAsync(result); + resultImage.Width.ShouldBe(width); + resultImage.Height.ShouldBe(height); + + resultImage[0, 0].A.ShouldBe((byte)0); + + resultImage[0, 1].R.ShouldBe((byte)255); + resultImage[0, 1].A.ShouldBe((byte)255); + resultImage[1, 0].R.ShouldBe((byte)255); + resultImage[1, 0].A.ShouldBe((byte)255); + resultImage[1, 1].R.ShouldBe((byte)255); + resultImage[1, 1].A.ShouldBe((byte)255); + } + + [Fact] + public async Task SaveImageAsync_WhenCalled_ItShouldSaveImageToDiskAtProvidedPath() + { + using var image = new Image(100, 100); + var stream = new MemoryStream(); + await image.SaveAsPngAsync(stream); + + var path = $"{Guid.NewGuid()}.png"; + await _sut.SaveImageAsync(stream, path); + + File.Exists(path).ShouldBeTrue(); + File.Delete(path); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (_isDisposed) + { + return; + } + + if (disposing) + { + File.Delete(TestImagePath); + _testImageStream.Dispose(); + } + + _isDisposed = true; + } +} \ No newline at end of file diff --git a/src/BGR.Console.Tests/Unit/ModNetModelTests.cs b/src/BGR.Console.Tests/Unit/ModNetModelTests.cs new file mode 100644 index 0000000..7972fe5 --- /dev/null +++ b/src/BGR.Console.Tests/Unit/ModNetModelTests.cs @@ -0,0 +1,66 @@ +namespace BGR.Console.Tests.Unit; + +public class ModNetModelTests +{ + private readonly byte[] _sampleModelBytes = [0x01, 0x02, 0x03]; + private readonly ModNetModel _sut; + + public ModNetModelTests() + { + _sut = new ModNetModel(_sampleModelBytes); + } + + [Fact] + public void Id_WhenCalled_ItShouldReturnModNetId() + { + ModNetModel.Id.ShouldBe("modnet"); + } + + [Fact] + public void InputWidth_WhenCalled_ItShouldReturn512() + { + _sut.InputWidth.ShouldBe(512); + } + + [Fact] + public void InputHeight_WhenCalled_ItShouldReturn512() + { + _sut.InputHeight.ShouldBe(512); + } + + [Fact] + public void RedNormalizationMean_WhenCalled_ItShouldReturnCorrectValue() + { + _sut.RedNormalizationMean.ShouldBe(0.485f); + } + + [Fact] + public void GreenNormalizationMean_WhenCalled_ItShouldReturnCorrectValue() + { + _sut.GreenNormalizationMean.ShouldBe(0.456f); + } + + [Fact] + public void BlueNormalizationMean_WhenCalled_ItShouldReturnCorrectValue() + { + _sut.BlueNormalizationMean.ShouldBe(0.406f); + } + + [Fact] + public void RedNormalizationStd_WhenCalled_ItShouldReturnCorrectValue() + { + _sut.RedNormalizationStd.ShouldBe(0.229f); + } + + [Fact] + public void GreenNormalizationStd_WhenCalled_ItShouldReturnCorrectValue() + { + _sut.GreenNormalizationStd.ShouldBe(0.224f); + } + + [Fact] + public void BlueNormalizationStd_WhenCalled_ItShouldReturnCorrectValue() + { + _sut.BlueNormalizationStd.ShouldBe(0.225f); + } +} \ No newline at end of file diff --git a/src/BGR.Console.Tests/Unit/ModelFactoryTests.cs b/src/BGR.Console.Tests/Unit/ModelFactoryTests.cs new file mode 100644 index 0000000..209ad1d --- /dev/null +++ b/src/BGR.Console.Tests/Unit/ModelFactoryTests.cs @@ -0,0 +1,67 @@ +namespace BGR.Console.Tests.Unit; + +public class ModelFactoryTests +{ + private readonly Mock _resourceManagerMock; + private readonly ModelFactory _sut; + private readonly byte[] _sampleModelBytes = [0x01, 0x02, 0x03]; + + public ModelFactoryTests() + { + _resourceManagerMock = new Mock(); + _sut = new ModelFactory(_resourceManagerMock.Object); + } + + [Theory] + [InlineData("u2net.onnx", typeof(U2NetModel))] + [InlineData("rmbg.onnx", typeof(RmbgModel))] + [InlineData("modnet.onnx", typeof(ModNetModel))] + public void Create_WhenCalledWithValidModelName_ItShouldReturnCorrectModelType(string resourceName, Type expectedType) + { + SetupResourceManagerMock(resourceName); + + var result = _sut.Create(resourceName); + + result.ShouldBeOfType(expectedType); + VerifyResourceManagerCalled(resourceName); + } + + [Fact] + public void Create_WhenCalledWithUnknownModel_ItShouldThrowArgumentException() + { + var resourceName = "unknown.onnx"; + SetupResourceManagerMock(resourceName); + + var exception = Should.Throw(() => _sut.Create(resourceName)); + + exception.Message.ShouldBe($"Unknown model name: {resourceName}"); + VerifyResourceManagerCalled(resourceName); + } + + [Fact] + public void Create_WhenCalledWithU2NetModel_ItShouldReadTheResourceStreamToTheEnd() + { + var resourceName = $"{U2NetModel.Id}.onnx"; + var memoryStream = new MemoryStream(_sampleModelBytes); + + _resourceManagerMock.Setup(x => x.GetResource(resourceName)) + .Returns(memoryStream); + + _sut.Create(resourceName); + + memoryStream.Position.ShouldBe(memoryStream.Length); + } + + private void SetupResourceManagerMock(string resourceName) + { + var memoryStream = new MemoryStream(_sampleModelBytes); + + _resourceManagerMock.Setup(x => x.GetResource(resourceName)) + .Returns(memoryStream); + } + + private void VerifyResourceManagerCalled(string resourceName) + { + _resourceManagerMock.Verify(x => x.GetResource(resourceName), Times.Once); + } +} \ No newline at end of file diff --git a/src/BGR.Console.Tests/Unit/ModelTests.cs b/src/BGR.Console.Tests/Unit/ModelTests.cs new file mode 100644 index 0000000..ff6d839 --- /dev/null +++ b/src/BGR.Console.Tests/Unit/ModelTests.cs @@ -0,0 +1,94 @@ +namespace BGR.Console.Tests.Unit; + +public class ModelTests +{ + internal sealed class TestModel(byte[] modelBytes) : Model(modelBytes) + { + public override int InputWidth => 100; + public override int InputHeight => 100; + public override float RedNormalizationMean => 0.5f; + public override float GreenNormalizationMean => 0.5f; + public override float BlueNormalizationMean => 0.5f; + public override float RedNormalizationStd => 0.25f; + public override float GreenNormalizationStd => 0.25f; + public override float BlueNormalizationStd => 0.25f; + } + + private readonly byte[] _sampleBytes = [0x01, 0x02, 0x03]; + private readonly TestModel _sut; + private const float Delta = 0.00001f; + + public ModelTests() + { + _sut = new TestModel(_sampleBytes); + } + + [Theory] + [InlineData(0f)] + [InlineData(127.5f)] + [InlineData(255f)] + public void NormalizeRed_WhenCalled_ItShouldNormalizeCorrectly(float value) + { + var result = _sut.NormalizeRed(value); + + var expected = ((value / 255f) - _sut.RedNormalizationMean) / _sut.RedNormalizationStd; + result.ShouldBe(expected, Delta); + } + + [Theory] + [InlineData(0f)] + [InlineData(127.5f)] + [InlineData(255f)] + public void NormalizeGreen_WhenCalled_ItShouldNormalizeCorrectly(float value) + { + var result = _sut.NormalizeGreen(value); + + var expected = ((value / 255f) - _sut.GreenNormalizationMean) / _sut.GreenNormalizationStd; + result.ShouldBe(expected, Delta); + } + + [Theory] + [InlineData(0f)] + [InlineData(127.5f)] + [InlineData(255f)] + public void NormalizeBlue_WhenCalled_ItShouldNormalizeCorrectly(float value) + { + var result = _sut.NormalizeBlue(value); + + var expected = ((value / 255f) - _sut.BlueNormalizationMean) / _sut.BlueNormalizationStd; + result.ShouldBe(expected, Delta); + } + + [Theory] + [InlineData(-1f)] + [InlineData(256f)] + public void NormalizeRed_WhenCalledWithOutOfRangeValues_ItShouldStillNormalize(float value) + { + var result = _sut.NormalizeRed(value); + + var expected = ((value / 255f) - _sut.RedNormalizationMean) / _sut.RedNormalizationStd; + result.ShouldBe(expected, Delta); + } + + [Theory] + [InlineData(-1f)] + [InlineData(256f)] + public void NormalizeGreen_WhenCalledWithOutOfRangeValues_ItShouldStillNormalize(float value) + { + var result = _sut.NormalizeGreen(value); + + var expected = ((value / 255f) - _sut.GreenNormalizationMean) / _sut.GreenNormalizationStd; + result.ShouldBe(expected, Delta); + } + + [Theory] + [InlineData(-1f)] + [InlineData(256f)] + public void NormalizeBlue_WhenCalledWithOutOfRangeValues_ItShouldStillNormalize(float value) + { + var result = _sut.NormalizeBlue(value); + + var expected = ((value / 255f) - _sut.BlueNormalizationMean) / _sut.BlueNormalizationStd; + result.ShouldBe(expected, Delta); + } +} \ No newline at end of file diff --git a/src/BGR.Console.Tests/Unit/OnnxInferenceRunnerTests.cs b/src/BGR.Console.Tests/Unit/OnnxInferenceRunnerTests.cs new file mode 100644 index 0000000..ee9faae --- /dev/null +++ b/src/BGR.Console.Tests/Unit/OnnxInferenceRunnerTests.cs @@ -0,0 +1,34 @@ + +using Microsoft.ML.OnnxRuntime.Tensors; + +namespace BGR.Console.Tests.Unit; + +public class OnnxInferenceRunnerTests +{ + private const string TestModel = "u2net.onnx"; + private readonly ResourceManager _resourceManager = new(); + private readonly OnnxInferenceRunner _sut = new(); + private readonly byte[] _sampleModelBytes; + private readonly Mock> _mockInputTensor = new(); + + public OnnxInferenceRunnerTests() + { + var modelStream = _resourceManager.GetResource(TestModel); + var modelBytes = new byte[modelStream.Length]; + modelStream.ReadExactly(modelBytes); + _sampleModelBytes = modelBytes; + + _mockInputTensor + .Setup(static x => x.ToTensor()) + .Returns(new DenseTensor([1, 3, 320, 320])); + } + + [Fact] + public void Run_WhenCalledWithValidInput_ItShouldReturnOutput() + { + var result = _sut.Run(_sampleModelBytes, _mockInputTensor.Object); + + result.ShouldBeOfType(); + result.ShouldNotBeNull(); + } +} \ No newline at end of file diff --git a/src/BGR.Console.Tests/Unit/OnnxTensorTests.cs b/src/BGR.Console.Tests/Unit/OnnxTensorTests.cs new file mode 100644 index 0000000..aaddc61 --- /dev/null +++ b/src/BGR.Console.Tests/Unit/OnnxTensorTests.cs @@ -0,0 +1,121 @@ +using BGR.Console.Removal.Onnx; + +using Microsoft.ML.OnnxRuntime.Tensors; + +namespace BGR.Console.Tests.Unit; + +public class OnnxTensorTests +{ + private const int DefaultBatchSize = 1; + private const int DefaultChannels = 3; + private const int DefaultHeight = 4; + private const int DefaultWidth = 5; + + [Fact] + public void Constructor_WhenCalledWithDimensions_ItShouldCreateTensorWithCorrectDimensions() + { + var tensor = new OnnxTensor(DefaultBatchSize, DefaultChannels, DefaultHeight, DefaultWidth); + + tensor.Height.ShouldBe(DefaultHeight); + tensor.Width.ShouldBe(DefaultWidth); + } + + [Fact] + public void Constructor_WhenCalledWithExistingTensor_ItShouldCreateTensorWithSameDimensions() + { + var existingTensor = new DenseTensor([DefaultBatchSize, DefaultChannels, DefaultHeight, DefaultWidth]); + + var tensor = new OnnxTensor(existingTensor); + + tensor.Height.ShouldBe(DefaultHeight); + tensor.Width.ShouldBe(DefaultWidth); + } + + [Theory] + [InlineData(0, 0, 0, 0, 1.0f)] + [InlineData(0, 1, 2, 3, 2.5f)] + [InlineData(0, 2, 3, 4, -1.0f)] + public void SetValue_WhenCalledWithValidValues_ItShouldSetCorrectValueAtPosition(int batch, int channel, int y, int x, float expectedValue) + { + var tensor = new OnnxTensor(DefaultBatchSize, DefaultChannels, DefaultHeight, DefaultWidth); + + tensor.SetValue(batch, channel, y, x, expectedValue); + var actualValue = tensor.GetValue(batch, channel, y, x); + + actualValue.ShouldBe(expectedValue); + } + + [Theory] + [InlineData(0, 0, 0, 0, 1.0f)] + [InlineData(0, 1, 2, 3, 2.5f)] + [InlineData(0, 2, 3, 4, -1.0f)] + public void GetValue_WhenCalledWithValidValues_ItShouldReturnCorrectValue(int batch, int channel, int y, int x, float value) + { + var tensor = new OnnxTensor(DefaultBatchSize, DefaultChannels, DefaultHeight, DefaultWidth); + tensor.SetValue(batch, channel, y, x, value); + + var result = tensor.GetValue(batch, channel, y, x); + + result.ShouldBe(value); + } + + [Fact] + public void ToTensor_WhenCalled_ItShouldReturnUnderlyingTensor() + { + var tensor = new OnnxTensor(DefaultBatchSize, DefaultChannels, DefaultHeight, DefaultWidth); + const float testValue = 42.0f; + tensor.SetValue(0, 0, 0, 0, testValue); + + var result = tensor.ToTensor(); + + result.ShouldBeOfType>(); + result[0, 0, 0, 0].ShouldBe(testValue); + } + + [Theory] + [InlineData(-1, 0, 0, 0)] + [InlineData(1, 3, 0, 0)] + [InlineData(0, 0, 4, 0)] + [InlineData(0, 0, 0, 5)] + public void SetValue_WhenCalledWithInvalidIndices_ItShouldThrowIndexOutOfRangeException(int batch, int channel, int y, int x) + { + var tensor = new OnnxTensor(DefaultBatchSize, DefaultChannels, DefaultHeight, DefaultWidth); + + try + { + tensor.SetValue(batch, channel, y, x, 0); + } + catch (IndexOutOfRangeException ex) + { + ex.Message.ShouldBe("Index was outside the bounds of the array."); + } + } + + [Theory] + [InlineData(-1, 0, 0, 0)] + [InlineData(1, 3, 0, 0)] + [InlineData(0, 0, 4, 0)] + [InlineData(0, 0, 0, 5)] + public void GetValue_WhenCalledWithInvalidIndices_ItShouldThrowIndexOutOfRangeException(int batch, int channel, int y, int x) + { + var tensor = new OnnxTensor(DefaultBatchSize, DefaultChannels, DefaultHeight, DefaultWidth); + + try + { + tensor.GetValue(batch, channel, y, x); + } + catch (IndexOutOfRangeException ex) + { + ex.Message.ShouldBe("Index was outside the bounds of the array."); + } + } + + [Fact] + public void Constructor_WhenCalledWithZeroDimension_ItShouldCreateEmptyTensor() + { + var tensor = new OnnxTensor(0, 0, 0, 0); + + tensor.Height.ShouldBe(0); + tensor.Width.ShouldBe(0); + } +} \ No newline at end of file diff --git a/src/BGR.Console.Tests/Unit/RemovalCommandTests.cs b/src/BGR.Console.Tests/Unit/RemovalCommandTests.cs new file mode 100644 index 0000000..a65ca11 --- /dev/null +++ b/src/BGR.Console.Tests/Unit/RemovalCommandTests.cs @@ -0,0 +1,181 @@ +using Microsoft.Extensions.Logging; + +using Spectre.Console.Cli; + +namespace BGR.Console.Tests.Unit; + +public class RemovalCommandTests : IDisposable +{ + private bool _isDisposed; + private readonly Mock _modelFactoryMock = new(); + private readonly Mock _imageProcessorMock = new(); + private readonly Mock _inferenceRunnerMock = new(); + private readonly TestConsole _console = new(); + private readonly Mock> _loggerMock = new(); + private readonly RemovalCommand _sut; + + public RemovalCommandTests() + { + _sut = new RemovalCommand( + _modelFactoryMock.Object, + _imageProcessorMock.Object, + _inferenceRunnerMock.Object, + _console, + _loggerMock.Object + ); + } + + [Theory] + [InlineData("output.png", false)] + [InlineData("", true)] + public async Task ExecuteAsync_WhenCalled_ItShouldProcessImage(string outputPath, bool includeMask) + { + var imagePath = $"{Guid.NewGuid()}.png"; + + using var testImage = new Image(100, 100); + await testImage.SaveAsPngAsync(imagePath); + + var resourceName = "u2net.onnx"; + + var settings = new RemovalCommand.Settings() + { + Image = imagePath, + Model = "u2net", + IncludeMask = includeMask, + Output = outputPath, + }; + + var model = new U2NetModel([1, 2, 3]); + var image = new SharpImage(100, 100, new MemoryStream()); + var inputTensor = new OnnxTensor(1, 3, 320, 320); + var outputTensor = new OnnxTensor(1, 1, 320, 320); + var maskStream = new MemoryStream(); + var outputStream = new MemoryStream(); + + _modelFactoryMock + .Setup(m => m.Create(resourceName)) + .Returns(model); + + _imageProcessorMock + .Setup(p => p.LoadImageAsync(imagePath)) + .ReturnsAsync(image); + + _imageProcessorMock + .Setup(p => p.CreateTensorInputAsync(image.Data, model)) + .ReturnsAsync(inputTensor); + + _inferenceRunnerMock + .Setup(r => r.Run(model.Bytes, inputTensor)) + .Returns(outputTensor); + + _imageProcessorMock + .Setup(p => p.GenerateMaskAsync(outputTensor, image.Width, image.Height)) + .ReturnsAsync(maskStream); + + _imageProcessorMock + .Setup(p => p.RemoveBackgroundAsync(image.Data, maskStream)) + .ReturnsAsync(outputStream); + + var commandContext = new CommandContext( + [], + new Mock().Object, + "test", + new object() + ); + + var result = await _sut.ExecuteAsync(commandContext, settings); + + result.ShouldBe(0); + + _modelFactoryMock.Verify(m => m.Create(resourceName), Times.Once); + _imageProcessorMock.Verify(p => p.LoadImageAsync(imagePath), Times.Once); + _imageProcessorMock.Verify(p => p.CreateTensorInputAsync(image.Data, model), Times.Once); + _inferenceRunnerMock.Verify(r => r.Run(model.Bytes, inputTensor), Times.Once); + _imageProcessorMock.Verify(p => p.GenerateMaskAsync(outputTensor, image.Width, image.Height), Times.Once); + _imageProcessorMock.Verify(p => p.RemoveBackgroundAsync(image.Data, maskStream), Times.Once); + _imageProcessorMock.Verify(p => p.SaveImageAsync(outputStream, It.IsAny()), Times.AtLeastOnce); + + File.Delete(imagePath); + } + + [Fact] + public void Validate_WhenCalledAndFileDoesNotExist_ItShouldReturnError() + { + var settings = new RemovalCommand.Settings() + { + Image = "invalid.png", + Model = "u2net", + IncludeMask = false, + Output = "output.png", + }; + + var result = settings.Validate(); + + result.Successful.ShouldBeFalse(); + } + + [Fact] + public void Validate_WhenCalledAndModelIsInvalid_ItShouldReturnError() + { + var imagePath = $"{Guid.NewGuid()}.png"; + + using var testImage = new Image(100, 100); + testImage.SaveAsPng(imagePath); + + var settings = new RemovalCommand.Settings() + { + Image = imagePath, + Model = "invalid", + IncludeMask = false, + Output = "output.png", + }; + + var result = settings.Validate(); + + result.Successful.ShouldBeFalse(); + } + + [Fact] + public void Validate_WhenCalledAndSettingsValid_ItShouldReturnSuccess() + { + var imagePath = $"{Guid.NewGuid()}.png"; + + using var testImage = new Image(100, 100); + testImage.SaveAsPng(imagePath); + + var settings = new RemovalCommand.Settings() + { + Image = imagePath, + Model = "u2net", + IncludeMask = false, + Output = "output.png", + }; + + var result = settings.Validate(); + + result.Successful.ShouldBeTrue(); + + File.Delete(imagePath); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (_isDisposed) + { + return; + } + + if (disposing) + { + _console.Dispose(); + } + + _isDisposed = true; + } +} \ No newline at end of file diff --git a/src/BGR.Console.Tests/Unit/ResourceManagerTests.cs b/src/BGR.Console.Tests/Unit/ResourceManagerTests.cs new file mode 100644 index 0000000..1f0eca7 --- /dev/null +++ b/src/BGR.Console.Tests/Unit/ResourceManagerTests.cs @@ -0,0 +1,26 @@ +namespace BGR.Console.Tests.Unit; + +public class ResourceManagerTests +{ + private readonly ResourceManager _resourceManager = new(); + + [Fact] + public void GetResource_WhenResourceExists_ItShouldReturnStream() + { + var resourceName = "u2net.onnx"; + + using var stream = _resourceManager.GetResource(resourceName); + + stream.ShouldNotBeNull(); + } + + [Fact] + public void GetResource_WhenResourceDoesNotExist_ItShouldThrowException() + { + var resourceName = "nonexistent.onnx"; + + var act = () => _resourceManager.GetResource(resourceName); + + act.ShouldThrow(); + } +} \ No newline at end of file diff --git a/src/BGR.Console.Tests/Unit/RmbgModelTests.cs b/src/BGR.Console.Tests/Unit/RmbgModelTests.cs new file mode 100644 index 0000000..b60c662 --- /dev/null +++ b/src/BGR.Console.Tests/Unit/RmbgModelTests.cs @@ -0,0 +1,66 @@ +namespace BGR.Console.Tests.Unit; + +public class RmbgModelTests +{ + private readonly byte[] _sampleModelBytes = [0x01, 0x02, 0x03]; + private readonly RmbgModel _sut; + + public RmbgModelTests() + { + _sut = new RmbgModel(_sampleModelBytes); + } + + [Fact] + public void Id_WhenCalled_ItShouldReturnRmbgId() + { + RmbgModel.Id.ShouldBe("rmbg"); + } + + [Fact] + public void InputWidth_WhenCalled_ItShouldReturn1024() + { + _sut.InputWidth.ShouldBe(1024); + } + + [Fact] + public void InputHeight_WhenCalled_ItShouldReturn1024() + { + _sut.InputHeight.ShouldBe(1024); + } + + [Fact] + public void RedNormalizationMean_WhenCalled_ItShouldHaveCorrectValue() + { + _sut.RedNormalizationMean.ShouldBe(0.485f); + } + + [Fact] + public void GreenNormalizationMean_WhenCalled_ItShouldHaveCorrectValue() + { + _sut.GreenNormalizationMean.ShouldBe(0.456f); + } + + [Fact] + public void BlueNormalizationMean_WhenCalled_ItShouldHaveCorrectValue() + { + _sut.BlueNormalizationMean.ShouldBe(0.406f); + } + + [Fact] + public void RedNormalizationStd_WhenCalled_ItShouldHaveCorrectValue() + { + _sut.RedNormalizationStd.ShouldBe(0.229f); + } + + [Fact] + public void GreenNormalizationStd_WhenCalled_ItShouldHaveCorrectValue() + { + _sut.GreenNormalizationStd.ShouldBe(0.224f); + } + + [Fact] + public void BlueNormalizationStd_WhenCalled_ItShouldHaveCorrectValue() + { + _sut.BlueNormalizationStd.ShouldBe(0.225f); + } +} \ No newline at end of file diff --git a/src/BGR.Console.Tests/Unit/SharpImageTests.cs b/src/BGR.Console.Tests/Unit/SharpImageTests.cs new file mode 100644 index 0000000..6c605d2 --- /dev/null +++ b/src/BGR.Console.Tests/Unit/SharpImageTests.cs @@ -0,0 +1,86 @@ +namespace BGR.Console.Tests.Unit; + +public class SharpImageTests : IDisposable +{ + private bool _isDisposed; + private const int DefaultWidth = 100; + private const int DefaultHeight = 200; + private readonly MemoryStream _sampleStream; + + public SharpImageTests() + { + _sampleStream = new MemoryStream([0x01, 0x02, 0x03]); + } + + [Fact] + public void Constructor_WhenCalled_ItShouldSetProperties() + { + var image = new SharpImage(DefaultWidth, DefaultHeight, _sampleStream); + + image.Width.ShouldBe(DefaultWidth); + image.Height.ShouldBe(DefaultHeight); + image.Data.ShouldBe(_sampleStream); + } + + [Theory] + [InlineData(1, 1)] + [InlineData(1920, 1080)] + [InlineData(int.MaxValue, int.MaxValue)] + public void Constructor_WhenCalledWithDifferentDimensions_ItShouldSetCorrectValues(int width, int height) + { + var image = new SharpImage(width, height, _sampleStream); + + image.Width.ShouldBe(width); + image.Height.ShouldBe(height); + } + + [Fact] + public void Constructor_WhenCalledWithNullStream_ItShouldThrowException() + { + var action = static () => new SharpImage(DefaultWidth, DefaultHeight, null!); + + action.ShouldThrow(); + } + + [Theory] + [InlineData(0, 0)] + [InlineData(-1, -1)] + [InlineData(1, -1)] + [InlineData(-1, 1)] + public void Constructor_WhenCalledWithInvalidDimensions_ItShouldThrowException(int width, int height) + { + var action = () => new SharpImage(width, height, _sampleStream); + + action.ShouldThrow(); + } + + [Fact] + public void Data_WhenCalled_ItShouldReturnSameStreamInstance() + { + var stream = new MemoryStream(); + var image = new SharpImage(DefaultWidth, DefaultHeight, stream); + + image.Data.ShouldBeSameAs(stream); + } + + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + protected virtual void Dispose(bool disposing) + { + if (_isDisposed) + { + return; + } + + if (disposing) + { + _sampleStream.Dispose(); + } + + _isDisposed = true; + } +} \ No newline at end of file diff --git a/src/BGR.Console.Tests/Unit/TypeRegistrarTests.cs b/src/BGR.Console.Tests/Unit/TypeRegistrarTests.cs new file mode 100644 index 0000000..a1ce7ba --- /dev/null +++ b/src/BGR.Console.Tests/Unit/TypeRegistrarTests.cs @@ -0,0 +1,84 @@ +namespace BGR.Console.Tests.Unit; + +public class TypeRegistrarTests +{ + [Fact] + public void Constructor_WhenCalled_ItShouldNotThrowShould() + { + var mockBuilder = new Mock(); + + Should.NotThrow(() => new TypeRegistrar(mockBuilder.Object)); + } + + [Fact] + public void Build_WhenCalled_ItShouldReturnResolverAndBuildHost() + { + var mockHost = new Mock(); + var mockBuilder = new Mock(); + + mockBuilder + .Setup(static b => b.Build()) + .Returns(mockHost.Object); + + var registrar = new TypeRegistrar(mockBuilder.Object); + + var resolver = registrar.Build(); + + resolver.ShouldBeOfType(); + mockBuilder.Verify(static b => b.Build(), Times.Once); + } + + [Fact] + public void Register_WhenCalledWithType_ItShouldAddToContainer() + { + var builder = Host.CreateDefaultBuilder(); + var registrar = new TypeRegistrar(builder); + registrar.Register(typeof(IService), typeof(ServiceImplementation)); + + using var host = builder.Build(); + var service = host.Services.GetService(); + + service.ShouldNotBeNull(); + service.ShouldBeOfType(); + } + + [Fact] + public void RegisterInstance_WhenCalledWithInstance_ItShouldAddToContainer() + { + var builder = Host.CreateDefaultBuilder(); + var registrar = new TypeRegistrar(builder); + var instance = new ServiceImplementation(); + registrar.RegisterInstance(typeof(IService), instance); + + using var host = builder.Build(); + var service = host.Services.GetService(); + + service.ShouldBeSameAs(instance); + } + + [Fact] + public void RegisterLazy_WhenCalledWithFunc_ItShouldAddToContainer() + { + var builder = Host.CreateDefaultBuilder(); + var registrar = new TypeRegistrar(builder); + registrar.RegisterLazy(typeof(IService), static () => new ServiceImplementation()); + + using var host = builder.Build(); + var service = host.Services.GetService(); + + service.ShouldNotBeNull(); + service.ShouldBeOfType(); + } + + [Fact] + public void RegisterLazy_WhenFuncIsNull_ItShouldThrow() + { + var builder = Host.CreateDefaultBuilder(); + var registrar = new TypeRegistrar(builder); + + Should.Throw(() => registrar.RegisterLazy(typeof(IService), null!)); + } + + private interface IService { } + private sealed class ServiceImplementation : IService { } +} \ No newline at end of file diff --git a/src/BGR.Console.Tests/Unit/TypeResolverTests.cs b/src/BGR.Console.Tests/Unit/TypeResolverTests.cs new file mode 100644 index 0000000..ea79300 --- /dev/null +++ b/src/BGR.Console.Tests/Unit/TypeResolverTests.cs @@ -0,0 +1,71 @@ +namespace BGR.Console.Tests.Unit; + +public class TypeResolverTests +{ + [Fact] + public void Constructor_WhenCalledWithNullHost_ItShouldThrowArgumentNullException() + { + Should.Throw(static () => new TypeResolver(null!)); + } + + [Fact] + public void Resolve_WhenTypeIsNull_ItShouldReturnNull() + { + var mockHost = new Mock(); + using var resolver = new TypeResolver(mockHost.Object); + + var result = resolver.Resolve(null); + + result.ShouldBeNull(); + } + + [Fact] + public void Resolve_WhenCalledWithRegisteredType_ItShouldReturnAnInstance() + { + var services = new ServiceCollection(); + services.AddSingleton(new TestService()); + + var mockHost = new Mock(); + + mockHost + .Setup(static h => h.Services) + .Returns(services.BuildServiceProvider()); + + using var resolver = new TypeResolver(mockHost.Object); + + var result = resolver.Resolve(typeof(TestService)); + + result.ShouldNotBeNull(); + result.ShouldBeOfType(); + } + + [Fact] + public void Resolve_WhenCalledWithUnregisteredType_ItShouldReturnNull() + { + var services = new ServiceCollection(); + var mockHost = new Mock(); + + mockHost + .Setup(static h => h.Services) + .Returns(services.BuildServiceProvider()); + + using var resolver = new TypeResolver(mockHost.Object); + + var result = resolver.Resolve(typeof(TestService)); + + result.ShouldBeNull(); + } + + [Fact] + public void Dispose_WhenCalled_ItShouldAlsoDisposeHost() + { + var mockHost = new Mock(); + var resolver = new TypeResolver(mockHost.Object); + + resolver.Dispose(); + + mockHost.Verify(static h => h.Dispose(), Times.Once); + } + + private sealed class TestService { } +} \ No newline at end of file diff --git a/src/BGR.Console.Tests/Unit/U2NetModelTests.cs b/src/BGR.Console.Tests/Unit/U2NetModelTests.cs new file mode 100644 index 0000000..ea09249 --- /dev/null +++ b/src/BGR.Console.Tests/Unit/U2NetModelTests.cs @@ -0,0 +1,66 @@ +namespace BGR.Console.Tests.Unit; + +public class U2NetModelTests +{ + private readonly byte[] _sampleModelBytes = [0x01, 0x02, 0x03]; + private readonly U2NetModel _sut; + + public U2NetModelTests() + { + _sut = new U2NetModel(_sampleModelBytes); + } + + [Fact] + public void Id_WhenCalled_ItShouldReturnU2NetId() + { + U2NetModel.Id.ShouldBe("u2net"); + } + + [Fact] + public void InputWidth_WhenCalled_ItShouldReturn320() + { + _sut.InputWidth.ShouldBe(320); + } + + [Fact] + public void InputHeight_WhenCalled_ItShouldReturn320() + { + _sut.InputHeight.ShouldBe(320); + } + + [Fact] + public void RedNormalizationMean_WhenCalled_ItShouldHaveCorrectValue() + { + _sut.RedNormalizationMean.ShouldBe(0.485f); + } + + [Fact] + public void GreenNormalizationMean_WhenCalled_ItShouldHaveCorrectValue() + { + _sut.GreenNormalizationMean.ShouldBe(0.456f); + } + + [Fact] + public void BlueNormalizationMean_WhenCalled_ItShouldHaveCorrectValue() + { + _sut.BlueNormalizationMean.ShouldBe(0.406f); + } + + [Fact] + public void RedNormalizationStd_WhenCalled_ItShouldHaveCorrectValue() + { + _sut.RedNormalizationStd.ShouldBe(0.229f); + } + + [Fact] + public void GreenNormalizationStd_WhenCalled_ItShouldHaveCorrectValue() + { + _sut.GreenNormalizationStd.ShouldBe(0.224f); + } + + [Fact] + public void BlueNormalizationStd_WhenCalled_ItShouldHaveCorrectValue() + { + _sut.BlueNormalizationStd.ShouldBe(0.225f); + } +} \ No newline at end of file diff --git a/src/BGR.Console.Tests/Usings.cs b/src/BGR.Console.Tests/Usings.cs new file mode 100644 index 0000000..ad944f2 --- /dev/null +++ b/src/BGR.Console.Tests/Usings.cs @@ -0,0 +1,19 @@ +global using BGR.Console.Common; +global using BGR.Console.Removal; +global using BGR.Console.Removal.ImageSharp; +global using BGR.Console.Removal.Models; +global using BGR.Console.Removal.Onnx; +global using BGR.Console.Resources; + +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Hosting; +global using Microsoft.Extensions.Logging; + +global using Moq; + +global using SixLabors.ImageSharp; +global using SixLabors.ImageSharp.PixelFormats; + +global using Spectre.Console; +global using Spectre.Console.Cli; +global using Spectre.Console.Testing; \ No newline at end of file diff --git a/src/BGR.Console/BGR.Console.csproj b/src/BGR.Console/BGR.Console.csproj new file mode 100644 index 0000000..6e256c7 --- /dev/null +++ b/src/BGR.Console/BGR.Console.csproj @@ -0,0 +1,38 @@ + + + + BGR.Console + BGR.Console + A command-line app for removing backgrounds from images. + 0.0.0 + Stevan Freeborn + true + true + true + Exe + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/BGR.Console/Common/HostBuilderExtensions.cs b/src/BGR.Console/Common/HostBuilderExtensions.cs new file mode 100644 index 0000000..dc69995 --- /dev/null +++ b/src/BGR.Console/Common/HostBuilderExtensions.cs @@ -0,0 +1,24 @@ +namespace BGR.Console.Common; + +internal static class HostBuilderExtensions +{ + public static CommandApp BuildApp(this IHostBuilder builder) + { + var registrar = new TypeRegistrar(builder); + var app = new CommandApp(registrar); + + app.Configure(static c => + c.SetExceptionHandler(static (ex, resolver) => + { + var logger = resolver?.Resolve(typeof(ILogger)) as ILogger; + logger?.RemovalCommandFailed(ex); + + var console = resolver?.Resolve(typeof(IAnsiConsole)) as IAnsiConsole; + console?.WriteLine($"[red]An error occurred while executing the command:[/]"); + console?.WriteException(ex, ExceptionFormats.ShortenEverything); + }) + ); + + return app; + } +} \ No newline at end of file diff --git a/src/BGR.Console/Common/TypeRegistrar.cs b/src/BGR.Console/Common/TypeRegistrar.cs new file mode 100644 index 0000000..4946cca --- /dev/null +++ b/src/BGR.Console/Common/TypeRegistrar.cs @@ -0,0 +1,28 @@ +namespace BGR.Console.Common; + +internal class TypeRegistrar(IHostBuilder builder) : ITypeRegistrar +{ + private readonly IHostBuilder _builder = builder; + + public ITypeResolver Build() + { + return new TypeResolver(_builder.Build()); + } + + public void Register(Type service, Type implementation) + { + _builder.ConfigureServices((_, services) => services.AddSingleton(service, implementation)); + } + + public void RegisterInstance(Type service, object implementation) + { + _builder.ConfigureServices((_, services) => services.AddSingleton(service, implementation)); + } + + public void RegisterLazy(Type service, Func func) + { + ArgumentNullException.ThrowIfNull(func); + + _builder.ConfigureServices((_, services) => services.AddSingleton(service, _ => func())); + } +} \ No newline at end of file diff --git a/src/BGR.Console/Common/TypeResolver.cs b/src/BGR.Console/Common/TypeResolver.cs new file mode 100644 index 0000000..b46ee74 --- /dev/null +++ b/src/BGR.Console/Common/TypeResolver.cs @@ -0,0 +1,16 @@ +namespace BGR.Console.Common; + +internal class TypeResolver(IHost provider) : ITypeResolver, IDisposable +{ + private readonly IHost _host = provider ?? throw new ArgumentNullException(nameof(provider)); + + public object? Resolve(Type? type) + { + return type is not null ? _host.Services.GetService(type) : null; + } + + public void Dispose() + { + _host.Dispose(); + } +} \ No newline at end of file diff --git a/src/BGR.Console/Logging/LoggerExtensions.cs b/src/BGR.Console/Logging/LoggerExtensions.cs new file mode 100644 index 0000000..49a897c --- /dev/null +++ b/src/BGR.Console/Logging/LoggerExtensions.cs @@ -0,0 +1,63 @@ +using System.Diagnostics; + +using ILogger = Microsoft.Extensions.Logging.ILogger; + +namespace BGR.Console.Logging; + +internal static class LoggerExtensions +{ + private static readonly Action RemovalCommandFailedMsg = LoggerMessage.Define( + LogLevel.Error, + new EventId(0, nameof(RemovalCommandFailed)), + "An error occurred while executing the command." + ); + + private static readonly Action TimeAndLogActionMsg = LoggerMessage.Define( + LogLevel.Information, + new EventId(0, nameof(TimeAndLogAction)), + "{Message} in {ElapsedMilliseconds}ms" + ); + + public static void RemovalCommandFailed(this ILogger logger, Exception ex) + { + RemovalCommandFailedMsg(logger, ex); + } + + public static async Task TimeAndLogActionAsync(this ILogger logger, string message, Func action) + { + var sw = new Stopwatch(); + sw.Start(); + await action(); + sw.Stop(); + TimeAndLogActionMsg(logger, message, sw.ElapsedMilliseconds, default!); + } + + public static async Task TimeAndLogActionAsync(this ILogger logger, string message, Func> action) + { + var sw = new Stopwatch(); + sw.Start(); + var result = await action(); + sw.Stop(); + TimeAndLogActionMsg(logger, message, sw.ElapsedMilliseconds, default!); + return result; + } + + public static void TimeAndLogAction(this ILogger logger, string message, Action action) + { + var sw = new Stopwatch(); + sw.Start(); + action(); + sw.Stop(); + TimeAndLogActionMsg(logger, message, sw.ElapsedMilliseconds, default!); + } + + public static T TimeAndLogAction(this ILogger logger, string message, Func action) + { + var sw = new Stopwatch(); + sw.Start(); + var result = action(); + sw.Stop(); + TimeAndLogActionMsg(logger, message, sw.ElapsedMilliseconds, default!); + return result; + } +} \ No newline at end of file diff --git a/src/BGR.Console/Program.cs b/src/BGR.Console/Program.cs new file mode 100644 index 0000000..33176af --- /dev/null +++ b/src/BGR.Console/Program.cs @@ -0,0 +1,41 @@ +Log.Logger = new LoggerConfiguration() + .WriteTo.File( + formatter: new CompactJsonFormatter(), + path: Path.Combine(AppContext.BaseDirectory, "logs", "log.jsonl"), + rollingInterval: RollingInterval.Day + ) + .Enrich.FromLogContext() + .MinimumLevel.Verbose() + .MinimumLevel.Override("Microsoft", LogEventLevel.Fatal) + .CreateLogger(); + +try +{ + var appName = Assembly.GetExecutingAssembly().GetName().Name; + Log.Information("Starting {AppName}", appName); + + await Host.CreateDefaultBuilder() + .ConfigureLogging(static logging => logging.ClearProviders()) + .ConfigureServices(static (_, services) => + { + services.AddSerilog(); + services.AddSingleton(AnsiConsole.Console); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + services.AddSingleton(); + }) + .BuildApp() + .RunAsync(args); + + Log.Information("Stopping {AppName}", appName); +} +catch (Exception ex) +{ + Log.Fatal(ex, "Application terminated unexpectedly"); + throw; +} +finally +{ + await Log.CloseAndFlushAsync(); +} \ No newline at end of file diff --git a/src/BGR.Console/Removal/IImage.cs b/src/BGR.Console/Removal/IImage.cs new file mode 100644 index 0000000..f25e08f --- /dev/null +++ b/src/BGR.Console/Removal/IImage.cs @@ -0,0 +1,8 @@ +namespace BGR.Console.Removal; + +internal interface IImage +{ + int Width { get; } + int Height { get; } + Stream Data { get; } +} \ No newline at end of file diff --git a/src/BGR.Console/Removal/IInferenceRunner.cs b/src/BGR.Console/Removal/IInferenceRunner.cs new file mode 100644 index 0000000..5966eb7 --- /dev/null +++ b/src/BGR.Console/Removal/IInferenceRunner.cs @@ -0,0 +1,6 @@ +namespace BGR.Console.Removal; + +internal interface IInferenceRunner +{ + ITensor Run(byte[] model, ITensor inputTensor); +} \ No newline at end of file diff --git a/src/BGR.Console/Removal/ITensor.cs b/src/BGR.Console/Removal/ITensor.cs new file mode 100644 index 0000000..716a8dd --- /dev/null +++ b/src/BGR.Console/Removal/ITensor.cs @@ -0,0 +1,10 @@ +namespace BGR.Console.Removal; + +internal interface ITensor +{ + int Height { get; } + int Width { get; } + void SetValue(int batch, int channel, int y, int x, T value); + float GetValue(int batch, int channel, int y, int x); + Tensor ToTensor(); +} \ No newline at end of file diff --git a/src/BGR.Console/Removal/ImageProcessor.cs b/src/BGR.Console/Removal/ImageProcessor.cs new file mode 100644 index 0000000..e864234 --- /dev/null +++ b/src/BGR.Console/Removal/ImageProcessor.cs @@ -0,0 +1,26 @@ +namespace BGR.Console.Removal; + +internal abstract class ImageProcessor +{ + public abstract Task LoadImageAsync(string path); + + public abstract Task> CreateTensorInputAsync(Stream image, Model model); + + public abstract Task GenerateMaskAsync(ITensor maskTensor, int width, int height); + + public abstract Task RemoveBackgroundAsync(Stream image, Stream mask); + + public abstract Task SaveImageAsync(Stream image, string path); + + protected static void WalkImage(int height, int width, Action action) + { + for (var y = 0; y < height; y++) + { + for (var x = 0; x < width; x++) + { + action(x, y); + } + } + } + +} \ No newline at end of file diff --git a/src/BGR.Console/Removal/ImageSharp/ImageSharpProcessor.cs b/src/BGR.Console/Removal/ImageSharp/ImageSharpProcessor.cs new file mode 100644 index 0000000..dca520e --- /dev/null +++ b/src/BGR.Console/Removal/ImageSharp/ImageSharpProcessor.cs @@ -0,0 +1,132 @@ +namespace BGR.Console.Removal.ImageSharp; + +internal class ImageSharpProcessor : ImageProcessor +{ + public override async Task LoadImageAsync(string path) + { + var image = await Image.LoadAsync(path); + + if (image.Metadata.DecodedImageFormat is null) + { + throw new InvalidOperationException("Image format is not supported."); + } + + var stream = new MemoryStream(); + await image.SaveAsync(stream, image.Metadata.DecodedImageFormat); + stream.Position = 0; + + return new SharpImage(image.Width, image.Height, stream); + } + + public override async Task> CreateTensorInputAsync(Stream image, Model model) + { + using var resized = await Image.LoadAsync(image); + + resized.Mutate(x => x.Resize(model.InputWidth, model.InputHeight)); + + const int batchSize = 1; + const int channels = 3; + var tensor = new OnnxTensor(batchSize, channels, model.InputHeight, model.InputWidth); + + WalkImage(resized.Height, resized.Width, (x, y) => + { + var pixel = resized[x, y]; + tensor.SetValue(0, 0, y, x, model.NormalizeRed(pixel.R)); + tensor.SetValue(0, 1, y, x, model.NormalizeGreen(pixel.G)); + tensor.SetValue(0, 2, y, x, model.NormalizeBlue(pixel.B)); + }); + + return tensor; + } + + public override async Task GenerateMaskAsync(ITensor maskTensor, int width, int height) + { + using var mask = new Image(width, height); + + using Image tempMask = new(maskTensor.Width, maskTensor.Height); + + const byte opaqueAlpha = 255; + + WalkImage(maskTensor.Height, maskTensor.Width, (x, y) => + { + var sigmoidValue = CalculateSigmoid(maskTensor.GetValue(0, 0, y, x)); + var normalizedValue = Normalize(sigmoidValue); + var intensity = ConvertToGreyscale(normalizedValue); + + tempMask[x, y] = new Rgba32(intensity, intensity, intensity, opaqueAlpha); + }); + + tempMask.Mutate(x => x.Resize(width, height)); + + WalkImage(height, width, (x, y) => mask[x, y] = tempMask[x, y]); + + var stream = new MemoryStream(); + await mask.SaveAsync(stream, new PngEncoder()); + stream.Position = 0; + + return stream; + } + + public override async Task RemoveBackgroundAsync(Stream image, Stream mask) + { + image.Position = 0; + mask.Position = 0; + + var imageWithBg = await Image.LoadAsync(image); + var maskImage = await Image.LoadAsync(mask); + using var imageWithBgRemoved = new Image(imageWithBg.Width, imageWithBg.Height); + + const byte alphaThreshold = 20; + var transparentPixel = new Rgba32(0, 0, 0, 0); + + WalkImage(imageWithBg.Height, imageWithBg.Width, (x, y) => + { + var sourcePixel = imageWithBg[x, y]; + var maskPixel = maskImage[x, y]; + + var alpha = maskPixel.R; + + imageWithBgRemoved[x, y] = alpha > alphaThreshold + ? new Rgba32(sourcePixel.R, sourcePixel.G, sourcePixel.B, sourcePixel.A) + : transparentPixel; + }); + + var result = new MemoryStream(); + await imageWithBgRemoved.SaveAsync(result, new PngEncoder()); + result.Position = 0; + + return result; + } + + public override async Task SaveImageAsync(Stream image, string path) + { + image.Position = 0; + var img = await Image.LoadAsync(image); + await img.SaveAsync(path, new PngEncoder()); + } + + private static float Normalize(float value) + { + const float binarizationThreshold = 0.5f; + const float normalizationFactor = 2f; + + return value > binarizationThreshold + ? (value - binarizationThreshold) * normalizationFactor + : 0f; + } + + private static byte ConvertToGreyscale(float value) + { + const float maxIntensity = 255f; + return (byte)(value * maxIntensity); + } + + private static float CalculateSigmoid(float x) + { + const float sigmoidScale = 1f; + const float sigmoidShift = 1f; + const float sigmoidDivisor = -1f; + + return sigmoidScale / (sigmoidShift + MathF.Exp(sigmoidDivisor * x)); + } +} \ No newline at end of file diff --git a/src/BGR.Console/Removal/ImageSharp/SharpImage.cs b/src/BGR.Console/Removal/ImageSharp/SharpImage.cs new file mode 100644 index 0000000..dc0f952 --- /dev/null +++ b/src/BGR.Console/Removal/ImageSharp/SharpImage.cs @@ -0,0 +1,25 @@ +namespace BGR.Console.Removal.ImageSharp; + +internal class SharpImage : IImage +{ + public int Width { get; } + public int Height { get; } + public Stream Data { get; } + + public SharpImage(int width, int height, Stream data) + { + if (width <= 0) + { + throw new ArgumentOutOfRangeException(nameof(width), "must be greater than 0"); + } + + if (height <= 0) + { + throw new ArgumentOutOfRangeException(nameof(height), "must be greater than 0"); + } + + Width = width; + Height = height; + Data = data ?? throw new ArgumentNullException(nameof(data)); + } +} \ No newline at end of file diff --git a/src/BGR.Console/Removal/Models/IModelFactory.cs b/src/BGR.Console/Removal/Models/IModelFactory.cs new file mode 100644 index 0000000..02e1389 --- /dev/null +++ b/src/BGR.Console/Removal/Models/IModelFactory.cs @@ -0,0 +1,6 @@ +namespace BGR.Console.Removal.Models; + +internal interface IModelFactory +{ + Model Create(string resourceName); +} \ No newline at end of file diff --git a/src/BGR.Console/Removal/Models/ModNetModel.cs b/src/BGR.Console/Removal/Models/ModNetModel.cs new file mode 100644 index 0000000..682d10f --- /dev/null +++ b/src/BGR.Console/Removal/Models/ModNetModel.cs @@ -0,0 +1,14 @@ +namespace BGR.Console.Removal.Models; + +internal class ModNetModel(byte[] modelBytes) : Model(modelBytes) +{ + public const string Id = "modnet"; + public override int InputWidth => 512; + public override int InputHeight => 512; + public override float RedNormalizationMean => 0.485f; + public override float GreenNormalizationMean => 0.456f; + public override float BlueNormalizationMean => 0.406f; + public override float RedNormalizationStd => 0.229f; + public override float GreenNormalizationStd => 0.224f; + public override float BlueNormalizationStd => 0.225f; +} \ No newline at end of file diff --git a/src/BGR.Console/Removal/Models/Model.cs b/src/BGR.Console/Removal/Models/Model.cs new file mode 100644 index 0000000..b6f89b0 --- /dev/null +++ b/src/BGR.Console/Removal/Models/Model.cs @@ -0,0 +1,44 @@ +namespace BGR.Console.Removal.Models; + +internal abstract class Model +{ + private const float PixelMax = 255f; + public abstract int InputWidth { get; } + public abstract int InputHeight { get; } + public abstract float RedNormalizationMean { get; } + public abstract float GreenNormalizationMean { get; } + public abstract float BlueNormalizationMean { get; } + public abstract float RedNormalizationStd { get; } + public abstract float GreenNormalizationStd { get; } + public abstract float BlueNormalizationStd { get; } + public byte[] Bytes { get; } = []; + + internal Model() + { + } + + protected Model(byte[] modelBytes) + { + Bytes = modelBytes; + } + + public virtual float NormalizeRed(float value) + { + return Normalize(value, RedNormalizationMean, RedNormalizationStd); + } + + public virtual float NormalizeGreen(float value) + { + return Normalize(value, GreenNormalizationMean, GreenNormalizationStd); + } + + public virtual float NormalizeBlue(float value) + { + return Normalize(value, BlueNormalizationMean, BlueNormalizationStd); + } + + private static float Normalize(float value, float mean, float std) + { + return ((value / PixelMax) - mean) / std; + } +} \ No newline at end of file diff --git a/src/BGR.Console/Removal/Models/ModelFactory.cs b/src/BGR.Console/Removal/Models/ModelFactory.cs new file mode 100644 index 0000000..eed8f5c --- /dev/null +++ b/src/BGR.Console/Removal/Models/ModelFactory.cs @@ -0,0 +1,21 @@ +namespace BGR.Console.Removal.Models; + +internal class ModelFactory(IResourceManager resourceManager) : IModelFactory +{ + private readonly IResourceManager _resourceManager = resourceManager; + + public Model Create(string resourceName) + { + var resource = _resourceManager.GetResource(resourceName); + var model = new byte[resource.Length]; + resource.ReadExactly(model); + + return resourceName switch + { + $"{U2NetModel.Id}.onnx" => new U2NetModel(model), + $"{RmbgModel.Id}.onnx" => new RmbgModel(model), + $"{ModNetModel.Id}.onnx" => new ModNetModel(model), + _ => throw new ArgumentException($"Unknown model name: {resourceName}") + }; + } +} \ No newline at end of file diff --git a/src/BGR.Console/Removal/Models/RmbgModel.cs b/src/BGR.Console/Removal/Models/RmbgModel.cs new file mode 100644 index 0000000..0104b94 --- /dev/null +++ b/src/BGR.Console/Removal/Models/RmbgModel.cs @@ -0,0 +1,14 @@ +namespace BGR.Console.Removal.Models; + +internal class RmbgModel(byte[] modelBytes) : Model(modelBytes) +{ + public const string Id = "rmbg"; + public override int InputWidth => 1024; + public override int InputHeight => 1024; + public override float RedNormalizationMean => 0.485f; + public override float GreenNormalizationMean => 0.456f; + public override float BlueNormalizationMean => 0.406f; + public override float RedNormalizationStd => 0.229f; + public override float GreenNormalizationStd => 0.224f; + public override float BlueNormalizationStd => 0.225f; +} \ No newline at end of file diff --git a/src/BGR.Console/Removal/Models/U2NetModel.cs b/src/BGR.Console/Removal/Models/U2NetModel.cs new file mode 100644 index 0000000..c0234b8 --- /dev/null +++ b/src/BGR.Console/Removal/Models/U2NetModel.cs @@ -0,0 +1,14 @@ +namespace BGR.Console.Removal.Models; + +internal class U2NetModel(byte[] modelBytes) : Model(modelBytes) +{ + public const string Id = "u2net"; + public override int InputWidth => 320; + public override int InputHeight => 320; + public override float RedNormalizationMean => 0.485f; + public override float GreenNormalizationMean => 0.456f; + public override float BlueNormalizationMean => 0.406f; + public override float RedNormalizationStd => 0.229f; + public override float GreenNormalizationStd => 0.224f; + public override float BlueNormalizationStd => 0.225f; +} \ No newline at end of file diff --git a/src/BGR.Console/Removal/Onnx/OnnxInferenceRunner.cs b/src/BGR.Console/Removal/Onnx/OnnxInferenceRunner.cs new file mode 100644 index 0000000..3abd2b3 --- /dev/null +++ b/src/BGR.Console/Removal/Onnx/OnnxInferenceRunner.cs @@ -0,0 +1,18 @@ +namespace BGR.Console.Removal.Onnx; + +internal class OnnxInferenceRunner : IInferenceRunner +{ + public ITensor Run(byte[] model, ITensor inputTensor) + { + using var options = new SessionOptions() { LogSeverityLevel = OrtLoggingLevel.ORT_LOGGING_LEVEL_ERROR }; + using var session = new InferenceSession(model, options); + var inputs = new List() + { + NamedOnnxValue.CreateFromTensor(session.InputNames[0], inputTensor.ToTensor()), + }; + + var results = session.Run(inputs); + var outputTensor = results[0].AsTensor(); + return new OnnxTensor(outputTensor); + } +} \ No newline at end of file diff --git a/src/BGR.Console/Removal/Onnx/OnnxTensor.cs b/src/BGR.Console/Removal/Onnx/OnnxTensor.cs new file mode 100644 index 0000000..512704a --- /dev/null +++ b/src/BGR.Console/Removal/Onnx/OnnxTensor.cs @@ -0,0 +1,51 @@ +namespace BGR.Console.Removal.Onnx; + +public class OnnxTensor : ITensor +{ + private readonly Tensor _tensor; + + public int Height => _tensor.Dimensions[2]; + + public int Width => _tensor.Dimensions[3]; + + public OnnxTensor( + int batchSize, + int channels, + int height, + int width + ) + { + _tensor = new DenseTensor([batchSize, channels, height, width]); + } + + public OnnxTensor(Tensor tensor) + { + _tensor = tensor; + } + + public void SetValue( + int batch, + int channel, + int y, + int x, + float value + ) + { + _tensor[batch, channel, y, x] = value; + } + + public float GetValue( + int batch, + int channel, + int y, + int x + ) + { + return _tensor[batch, channel, y, x]; + } + + public Tensor ToTensor() + { + return _tensor; + } +} \ No newline at end of file diff --git a/src/BGR.Console/Removal/RemovalCommand.cs b/src/BGR.Console/Removal/RemovalCommand.cs new file mode 100644 index 0000000..c7c6bee --- /dev/null +++ b/src/BGR.Console/Removal/RemovalCommand.cs @@ -0,0 +1,139 @@ +using System.Diagnostics; + +namespace BGR.Console.Removal; + +internal class RemovalCommand( + IModelFactory modelFactory, + ImageProcessor imageProcessor, + IInferenceRunner inferenceRunner, + IAnsiConsole console, + ILogger logger +) : AsyncCommand +{ + private readonly IModelFactory _modelFactory = modelFactory; + private readonly ImageProcessor _imageProcessor = imageProcessor; + private readonly IInferenceRunner _inferenceRunner = inferenceRunner; + private readonly IAnsiConsole _console = console; + private readonly ILogger _logger = logger; + + public override async Task ExecuteAsync(CommandContext context, Settings settings) + { + await _console.Status() + .Spinner(Spinner.Known.Dots) + .SpinnerStyle(Style.Parse("green")) + .StartAsync("Removing background...", async ctx => + { + ctx.Status("Loading model..."); + var model = _logger.TimeAndLogAction( + "Loading model", + () => _modelFactory.Create(settings.ResourceName) + ); + + ctx.Status("Loading image..."); + var image = await _logger.TimeAndLogActionAsync( + "Loading image", + async () => await _imageProcessor.LoadImageAsync(settings.Image) + ); + + ctx.Status("Creating tensor input..."); + var inputTensor = await _logger.TimeAndLogActionAsync( + "Creating tensor input", + async () => await _imageProcessor.CreateTensorInputAsync(image.Data, model) + ); + + ctx.Status("Running inference..."); + var outputTensor = _logger.TimeAndLogAction( + "Running inference", + () => _inferenceRunner.Run(model.Bytes, inputTensor) + ); + + ctx.Status("Generating mask..."); + var mask = await _logger.TimeAndLogActionAsync( + "Generating mask", + async () => await _imageProcessor.GenerateMaskAsync(outputTensor, image.Width, image.Height) + ); + + ctx.Status("Removing background..."); + var output = await _logger.TimeAndLogActionAsync( + "Removing background", + async () => await _imageProcessor.RemoveBackgroundAsync(image.Data, mask) + ); + + if (settings.IncludeMask) + { + await _logger.TimeAndLogActionAsync( + "Saving mask", + async () => await _imageProcessor.SaveImageAsync(mask, settings.MaskPath) + ); + + _console.MarkupLine($"[bold]Mask saved to:[/] [blue]{settings.OutputPath}[/]"); + } + + await _logger.TimeAndLogActionAsync( + "Saving output", + async () => await _imageProcessor.SaveImageAsync(output, settings.OutputPath) + ); + + _console.MarkupLine($"[bold]Output saved to:[/] [green]{settings.OutputPath}[/]"); + }); + + return 0; + } + + internal class Settings : CommandSettings + { + private static readonly Dictionary Models = new() + { + { RmbgModel.Id, "rmbg.onnx" }, + { ModNetModel.Id, "modnet.onnx" }, + { U2NetModel.Id, "u2net.onnx" }, + }; + + [CommandArgument(0, "")] + [Description("Path to the image file whose background you want to remove")] + public string Image { get; init; } = string.Empty; + + [CommandOption("--model|-m")] + [Description("The model to use for background removal")] + public string Model { get; init; } = "rmbg"; + + [CommandOption("--include-mask|-i")] + [Description("Generate and output the mask used for background removal")] + public bool IncludeMask { get; init; } = false; + + [CommandOption("--output|-o")] + [Description("Path to output image without background to. File extension will always be .png")] + public string Output { get; init; } = string.Empty; + + public string ResourceName => Models[Model]; + + public string MaskPath => GetOutputPath("_mask"); + + public string OutputPath => GetOutputPath("_no_bg"); + + public override ValidationResult Validate() + { + if (File.Exists(Image) is false) + { + return ValidationResult.Error($"The image file '{Image}' does not exist."); + } + + if (Models.ContainsKey(Model) is false) + { + return ValidationResult.Error($"The model '{Model}' is not supported."); + } + + return ValidationResult.Success(); + } + + private string GetOutputPath(string modifier) + { + if (string.IsNullOrWhiteSpace(Output)) + { + return Path.ChangeExtension(Image, null) + modifier + ".png"; + } + + return Path.ChangeExtension(Output, ".png"); + } + } +} \ No newline at end of file diff --git a/BGR.Console/modnet.onnx b/src/BGR.Console/Resources/Files/modnet.onnx similarity index 100% rename from BGR.Console/modnet.onnx rename to src/BGR.Console/Resources/Files/modnet.onnx diff --git a/BGR.Console/rmbg.onnx b/src/BGR.Console/Resources/Files/rmbg.onnx similarity index 100% rename from BGR.Console/rmbg.onnx rename to src/BGR.Console/Resources/Files/rmbg.onnx diff --git a/BGR.Console/u2net.onnx b/src/BGR.Console/Resources/Files/u2net.onnx similarity index 100% rename from BGR.Console/u2net.onnx rename to src/BGR.Console/Resources/Files/u2net.onnx diff --git a/src/BGR.Console/Resources/IResourceManager.cs b/src/BGR.Console/Resources/IResourceManager.cs new file mode 100644 index 0000000..e10254e --- /dev/null +++ b/src/BGR.Console/Resources/IResourceManager.cs @@ -0,0 +1,6 @@ +namespace BGR.Console.Resources; + +internal interface IResourceManager +{ + Stream GetResource(string resourceName); +} \ No newline at end of file diff --git a/src/BGR.Console/Resources/ResourceManager.cs b/src/BGR.Console/Resources/ResourceManager.cs new file mode 100644 index 0000000..97d6df5 --- /dev/null +++ b/src/BGR.Console/Resources/ResourceManager.cs @@ -0,0 +1,13 @@ +namespace BGR.Console.Resources; + +internal sealed class ResourceManager : IResourceManager +{ + public Stream GetResource(string resourceName) + { + var name = $"{nameof(BGR)}.{nameof(Console)}.{nameof(Resources)}.Files.{resourceName}"; + var assembly = Assembly.GetExecutingAssembly(); + var names = assembly.GetManifestResourceNames(); + var stream = assembly.GetManifestResourceStream(name); + return stream ?? throw new FileNotFoundException("Model not found in embedded resources."); + } +} \ No newline at end of file diff --git a/src/BGR.Console/Usings.cs b/src/BGR.Console/Usings.cs new file mode 100644 index 0000000..11f5a3f --- /dev/null +++ b/src/BGR.Console/Usings.cs @@ -0,0 +1,28 @@ +global using System.ComponentModel; +global using System.Reflection; + +global using BGR.Console.Common; +global using BGR.Console.Logging; +global using BGR.Console.Removal; +global using BGR.Console.Removal.ImageSharp; +global using BGR.Console.Removal.Models; +global using BGR.Console.Removal.Onnx; +global using BGR.Console.Resources; + +global using Microsoft.Extensions.DependencyInjection; +global using Microsoft.Extensions.Hosting; +global using Microsoft.Extensions.Logging; +global using Microsoft.ML.OnnxRuntime; +global using Microsoft.ML.OnnxRuntime.Tensors; + +global using Serilog; +global using Serilog.Events; +global using Serilog.Formatting.Compact; + +global using SixLabors.ImageSharp; +global using SixLabors.ImageSharp.Formats.Png; +global using SixLabors.ImageSharp.PixelFormats; +global using SixLabors.ImageSharp.Processing; + +global using Spectre.Console; +global using Spectre.Console.Cli; \ No newline at end of file diff --git a/src/BGR.sln b/src/BGR.sln new file mode 100644 index 0000000..c76a49c --- /dev/null +++ b/src/BGR.sln @@ -0,0 +1,28 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BGR.Console", "BGR.Console\BGR.Console.csproj", "{2630F4B7-8188-40DF-BFF0-84B2EA8C9994}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BGR.Console.Tests", "BGR.Console.Tests\BGR.Console.Tests.csproj", "{C5F69AD0-2CE5-4961-A74D-194CF6D812CA}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {2630F4B7-8188-40DF-BFF0-84B2EA8C9994}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {2630F4B7-8188-40DF-BFF0-84B2EA8C9994}.Debug|Any CPU.Build.0 = Debug|Any CPU + {2630F4B7-8188-40DF-BFF0-84B2EA8C9994}.Release|Any CPU.ActiveCfg = Release|Any CPU + {2630F4B7-8188-40DF-BFF0-84B2EA8C9994}.Release|Any CPU.Build.0 = Release|Any CPU + {C5F69AD0-2CE5-4961-A74D-194CF6D812CA}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {C5F69AD0-2CE5-4961-A74D-194CF6D812CA}.Debug|Any CPU.Build.0 = Debug|Any CPU + {C5F69AD0-2CE5-4961-A74D-194CF6D812CA}.Release|Any CPU.ActiveCfg = Release|Any CPU + {C5F69AD0-2CE5-4961-A74D-194CF6D812CA}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection +EndGlobal diff --git a/src/Directory.Build.props b/src/Directory.Build.props new file mode 100644 index 0000000..141c0c3 --- /dev/null +++ b/src/Directory.Build.props @@ -0,0 +1,13 @@ + + + net9.0 + enable + enable + latest + All + true + true + true + + +