19 Commits
Author SHA1 Message Date
Stevan Freeborn b7e530e78f chore(release): 0.0.0 [skip ci] 2025-02-14 06:26:04 +00:00
Stevan Freeborn 2d1dbd00f3 Merge pull request #1 from StevanFreeborn/stevanfreeborn/chore/update-build-to-fetch-resource-files
chore: fix workflows and build now that model files are not in the repository
2025-02-14 00:25:43 -06:00
Stevan Freeborn 7bed6cb2c1 chore: remove ignore path from workflows 2025-02-14 00:22:12 -06:00
Stevan Freeborn 3073a4038e chore: put embedded resources item group in target where models are downloaded 2025-02-14 00:12:54 -06:00
Stevan Freeborn 037b888280 chore: add step for listing models for debugging workflow 2025-02-13 23:30:02 -06:00
Stevan Freeborn 44386edbe4 chore: fix workflows and build now that model files are not in the repository 2025-02-13 23:20:59 -06:00
Stevan Freeborn a0984dd9c9 chore: remove attributes file 2025-02-13 22:53:15 -06:00
Stevan Freeborn 214e86dc8c chore: remove model files 2025-02-13 22:47:15 -06:00
Stevan Freeborn ddd8cb7ea4 Merge pull request #2 from StevanFreeborn/stevanfreeborn/chore/add-license
chore: add license
2025-02-13 20:34:58 -06:00
Stevan Freeborn 5fdf608435 chore: update workflow to ignore proper license file and rename publish workflow 2025-02-13 20:32:39 -06:00
Stevan Freeborn e13f7b06b6 chore: add license 2025-02-13 20:31:08 -06:00
Stevan Freeborn 2387cc9ba7 chore(release): 0.0.0 [skip ci] 2025-02-14 02:03:34 +00:00
Stevan Freeborn c974ed8387 Merge pull request #1 from StevanFreeborn/stevanfreeborn/feat/refactor-toward-proper-console-app
feat: refactor to proper app
2025-02-13 20:03:01 -06:00
Stevan Freeborn 52038b3196 chore: include lfs files when checking out repo in workflow 2025-02-13 19:58:08 -06:00
Stevan Freeborn 58510ff812 chore: fix workflow runner image selection 2025-02-13 19:49:50 -06:00
GitHub Actions 754f1a626a chore: format fixes [skip ci] 2025-02-14 01:45:35 +00:00
Stevan Freeborn 5aae926f1b chore: add workflows 2025-02-13 19:31:09 -06:00
Stevan Freeborn 42435ad915 feat: refactor to proper app + write tests 2025-02-13 17:24:38 -06:00
Stevan Freeborn baf4035616 feat: working on adding abstractions 2025-02-09 23:22:21 -06:00
54 changed files with 2828 additions and 229 deletions
+387
View File
@@ -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
+100
View File
@@ -0,0 +1,100 @@
name: Publish
on:
workflow_dispatch:
push:
paths-ignore:
- "**/README.md"
- "**/.gitignore"
- "**/.editorconfig"
- "**/LICENSE.md"
- .github/**
- src/BGR.Console.Tests/**
branches:
- main
jobs:
version:
runs-on: ubuntu-latest
steps:
- name: Checkout repository
uses: actions/checkout@v4
with:
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:
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
+103
View File
@@ -0,0 +1,103 @@
name: Pull Request
on:
workflow_dispatch:
pull_request:
paths-ignore:
- "**/README.md"
- "**/LICENSE.md"
- "**/.gitignore"
- "**/.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:
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:
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 --no-restore src
- 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:
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 }}
+1 -1
View File
@@ -6,7 +6,7 @@
# dotenv files # dotenv files
.env .env
# models # model files
*.onnx *.onnx
# User-specific files # User-specific files
-21
View File
@@ -1,21 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.ML.OnnxRuntime" Version="1.20.1" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.6" />
</ItemGroup>
<ItemGroup>
<EmbeddedResource Include="modnet.onnx" />
<EmbeddedResource Include="rmbg.onnx" />
<EmbeddedResource Include="u2net.onnx" />
</ItemGroup>
</Project>
-207
View File
@@ -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 <input_image_path>");
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<Rgba32>(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>()
{
NamedOnnxValue.CreateFromTensor(session.InputNames[0], inputTensor),
};
using var results = session.Run(inputs);
var outputTensor = results[0].AsTensor<float>();
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<float> CreateTensorInput(Image<Rgba32> 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<float> 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<Rgba32> GenerateMask(Tensor<float> maskTensor, int width, int height)
{
var mask = new Image<Rgba32>(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<Rgba32> 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<Rgba32> GetImageWithBackgroundRemoved(Image<Rgba32> image, Image<Rgba32> mask)
{
Image<Rgba32> 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<int, int> action)
{
for (var y = 0; y < height; y++)
{
for (var x = 0; x < width; x++)
{
action(x, y);
}
}
}
+21
View File
@@ -0,0 +1,21 @@
# The MIT License (MIT)
Copyright (c) 2025 Stevan Freeborn
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+35
View File
@@ -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.
Binary file not shown.

After

Width:  |  Height:  |  Size: 915 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 382 KiB

@@ -0,0 +1,52 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<IsPackable>false</IsPackable>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="coverlet.msbuild" Version="6.0.4">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Moq" Version="4.20.72" />
<PackageReference Include="Shouldly" Version="4.3.0" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="Spectre.Console.Testing" Version="0.49.1" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<PropertyGroup>
<CollectCoverage>true</CollectCoverage>
<CoverletOutput>./TestResults/Coverage/</CoverletOutput>
<CoverletOutputFormat>cobertura</CoverletOutputFormat>
<Include>[BGR.Console]*</Include>
<ExcludeByFile>**/Program.cs</ExcludeByFile>
</PropertyGroup>
<Target Name="GenerateHtmlCoverageReport" AfterTargets="GenerateCoverageResultAfterTest">
<Exec Command="reportgenerator -reports:./TestResults/Coverage/*.xml -targetdir:./TestResults/Coverage/Report/ -reporttypes:Html_Dark" />
</Target>
<ItemGroup>
<Using Include="Xunit" />
<Using Include="Shouldly" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\BGR.Console\BGR.Console.csproj" />
</ItemGroup>
</Project>
@@ -0,0 +1,19 @@
namespace BGR.Console.Tests.Integration;
internal static class AppFactory
{
public static CommandApp<RemovalCommand> Create()
{
return Host.CreateDefaultBuilder()
.ConfigureLogging(static logging => logging.ClearProviders())
.ConfigureServices(static (_, services) =>
{
services.AddSingleton<IAnsiConsole>(new TestConsole());
services.AddSingleton<IResourceManager, ResourceManager>();
services.AddSingleton<ImageProcessor, ImageSharpProcessor>();
services.AddSingleton<IInferenceRunner, OnnxInferenceRunner>();
services.AddSingleton<IModelFactory, ModelFactory>();
})
.BuildApp();
}
}
@@ -0,0 +1,24 @@
namespace BGR.Console.Tests.Integration;
public class RemovalCommandTests
{
private readonly CommandApp<RemovalCommand> _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<Rgba32>(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);
}
}
@@ -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<Model> _modelMock = new();
private readonly Stream _testImageStream;
public ImageSharpProcessorTests()
{
if (File.Exists(TestImagePath) is false)
{
using var testImage = new Image<Rgba32>(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<Rgba32>(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<SharpImage>();
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<OnnxTensor>();
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<float>())).Returns(normalizedValue);
_modelMock.Setup(static x => x.NormalizeGreen(It.IsAny<float>())).Returns(normalizedValue);
_modelMock.Setup(static x => x.NormalizeBlue(It.IsAny<float>())).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<float>()), Times.AtLeast(1));
_modelMock.Verify(static x => x.NormalizeGreen(It.IsAny<float>()), Times.AtLeast(1));
_modelMock.Verify(static x => x.NormalizeBlue(It.IsAny<float>()), 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<Rgba32>(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<Rgba32>(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<Rgba32>(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<Rgba32>(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<Rgba32>(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<Rgba32>(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;
}
}
@@ -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);
}
}
@@ -0,0 +1,67 @@
namespace BGR.Console.Tests.Unit;
public class ModelFactoryTests
{
private readonly Mock<IResourceManager> _resourceManagerMock;
private readonly ModelFactory _sut;
private readonly byte[] _sampleModelBytes = [0x01, 0x02, 0x03];
public ModelFactoryTests()
{
_resourceManagerMock = new Mock<IResourceManager>();
_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<ArgumentException>(() => _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);
}
}
+94
View File
@@ -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);
}
}
@@ -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<ITensor<float>> _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<float>([1, 3, 320, 320]));
}
[Fact]
public void Run_WhenCalledWithValidInput_ItShouldReturnOutput()
{
var result = _sut.Run(_sampleModelBytes, _mockInputTensor.Object);
result.ShouldBeOfType<OnnxTensor>();
result.ShouldNotBeNull();
}
}
@@ -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<float>([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<DenseTensor<float>>();
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);
}
}
@@ -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<IModelFactory> _modelFactoryMock = new();
private readonly Mock<ImageProcessor> _imageProcessorMock = new();
private readonly Mock<IInferenceRunner> _inferenceRunnerMock = new();
private readonly TestConsole _console = new();
private readonly Mock<ILogger<RemovalCommand>> _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<Rgba32>(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<IRemainingArguments>().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<string>()), 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<Rgba32>(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<Rgba32>(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;
}
}
@@ -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<FileNotFoundException>();
}
}
@@ -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);
}
}
@@ -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<ArgumentNullException>();
}
[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<ArgumentOutOfRangeException>();
}
[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;
}
}
@@ -0,0 +1,84 @@
namespace BGR.Console.Tests.Unit;
public class TypeRegistrarTests
{
[Fact]
public void Constructor_WhenCalled_ItShouldNotThrowShould()
{
var mockBuilder = new Mock<IHostBuilder>();
Should.NotThrow(() => new TypeRegistrar(mockBuilder.Object));
}
[Fact]
public void Build_WhenCalled_ItShouldReturnResolverAndBuildHost()
{
var mockHost = new Mock<IHost>();
var mockBuilder = new Mock<IHostBuilder>();
mockBuilder
.Setup(static b => b.Build())
.Returns(mockHost.Object);
var registrar = new TypeRegistrar(mockBuilder.Object);
var resolver = registrar.Build();
resolver.ShouldBeOfType<TypeResolver>();
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<IService>();
service.ShouldNotBeNull();
service.ShouldBeOfType<ServiceImplementation>();
}
[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<IService>();
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<IService>();
service.ShouldNotBeNull();
service.ShouldBeOfType<ServiceImplementation>();
}
[Fact]
public void RegisterLazy_WhenFuncIsNull_ItShouldThrow()
{
var builder = Host.CreateDefaultBuilder();
var registrar = new TypeRegistrar(builder);
Should.Throw<ArgumentNullException>(() => registrar.RegisterLazy(typeof(IService), null!));
}
private interface IService { }
private sealed class ServiceImplementation : IService { }
}
@@ -0,0 +1,71 @@
namespace BGR.Console.Tests.Unit;
public class TypeResolverTests
{
[Fact]
public void Constructor_WhenCalledWithNullHost_ItShouldThrowArgumentNullException()
{
Should.Throw<ArgumentNullException>(static () => new TypeResolver(null!));
}
[Fact]
public void Resolve_WhenTypeIsNull_ItShouldReturnNull()
{
var mockHost = new Mock<IHost>();
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<IHost>();
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<TestService>();
}
[Fact]
public void Resolve_WhenCalledWithUnregisteredType_ItShouldReturnNull()
{
var services = new ServiceCollection();
var mockHost = new Mock<IHost>();
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<IHost>();
var resolver = new TypeResolver(mockHost.Object);
resolver.Dispose();
mockHost.Verify(static h => h.Dispose(), Times.Once);
}
private sealed class TestService { }
}
@@ -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);
}
}
+19
View File
@@ -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;
+54
View File
@@ -0,0 +1,54 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<AssemblyTitle>BGR.Console</AssemblyTitle>
<Product>BGR.Console</Product>
<Description>A command-line app for removing backgrounds from images.</Description>
<Version>0.0.0</Version>
<Authors>Stevan Freeborn</Authors>
<PublishSingleFile>true</PublishSingleFile>
<SelfContained>true</SelfContained>
<PublishReadyToRun>true</PublishReadyToRun>
<OutputType>Exe</OutputType>
</PropertyGroup>
<PropertyGroup>
<DownloadDirectory>Resources/Files</DownloadDirectory>
</PropertyGroup>
<Target Name="DownloadModels" BeforeTargets="PrepareForBuild">
<Message Text="Downloading models..." Importance="high" />
<MakeDir Directories="$(DownloadDirectory)" Condition="!Exists('$(DownloadDirectory)')" />
<Exec Condition="!Exists('$(DownloadDirectory)/rmbg.onnx')" Command="curl -L &quot;https://share.stevanfreeborn.com/bgr/rmbg.onnx&quot; -o &quot;$(DownloadDirectory)/rmbg.onnx&quot;" />
<Exec Condition="!Exists('$(DownloadDirectory)/u2net.onnx')" Command="curl -L &quot;https://share.stevanfreeborn.com/bgr/u2net.onnx&quot; -o &quot;$(DownloadDirectory)/u2net.onnx&quot;" />
<Exec Condition="!Exists('$(DownloadDirectory)/modnet.onnx')" Command="curl -L &quot;https://share.stevanfreeborn.com/bgr/modnet.onnx&quot; -o &quot;$(DownloadDirectory)/modnet.onnx&quot;" />
<ItemGroup>
<EmbeddedResource Include="Resources\Files\**\*" />
</ItemGroup>
</Target>
<ItemGroup>
<InternalsVisibleTo Include="$(AssemblyName).Tests" />
<InternalsVisibleTo Include="DynamicProxyGenAssembly2" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.1" />
<PackageReference Include="Microsoft.Extensions.Logging" Version="9.0.1" />
<PackageReference Include="Microsoft.Extensions.Options" Version="9.0.1" />
<PackageReference Include="Microsoft.ML.OnnxRuntime" Version="1.20.1" />
<PackageReference Include="Serilog" Version="4.2.0" />
<PackageReference Include="Serilog.Extensions.Hosting" Version="9.0.0" />
<PackageReference Include="Serilog.Formatting.Compact" Version="3.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.6" />
<PackageReference Include="Spectre.Console" Version="0.49.1" />
<PackageReference Include="Spectre.Console.Cli" Version="0.49.1" />
</ItemGroup>
</Project>
+22
View File
@@ -0,0 +1,22 @@
# Change Log
All notable changes to this project will be documented in this file. See [versionize](https://github.com/versionize/versionize) for commit guidelines.
<a name="0.0.0"></a>
## [0.0.0](https://www.github.com/StevanFreeborn/bgr/releases/tag/v0.0.0) (2025-02-14)
### Features
* initial working POC ([651869c](https://www.github.com/StevanFreeborn/bgr/commit/651869c27ff9ed390d78bf7ae6b81b3aa1acefb3))
* refactor to proper app + write tests ([42435ad](https://www.github.com/StevanFreeborn/bgr/commit/42435ad9150db459ee97a399c53050a7ec58012c))
* working on adding abstractions ([baf4035](https://www.github.com/StevanFreeborn/bgr/commit/baf40356164be64735ed53fa42966d664a41a3a9))
<a name="0.0.0"></a>
## [0.0.0](https://www.github.com/StevanFreeborn/bgr/releases/tag/v0.0.0) (2025-02-14)
### Features
* initial working POC ([651869c](https://www.github.com/StevanFreeborn/bgr/commit/651869c27ff9ed390d78bf7ae6b81b3aa1acefb3))
* refactor to proper app + write tests ([42435ad](https://www.github.com/StevanFreeborn/bgr/commit/42435ad9150db459ee97a399c53050a7ec58012c))
* working on adding abstractions ([baf4035](https://www.github.com/StevanFreeborn/bgr/commit/baf40356164be64735ed53fa42966d664a41a3a9))
@@ -0,0 +1,24 @@
namespace BGR.Console.Common;
internal static class HostBuilderExtensions
{
public static CommandApp<RemovalCommand> BuildApp(this IHostBuilder builder)
{
var registrar = new TypeRegistrar(builder);
var app = new CommandApp<RemovalCommand>(registrar);
app.Configure(static c =>
c.SetExceptionHandler(static (ex, resolver) =>
{
var logger = resolver?.Resolve(typeof(ILogger<RemovalCommand>)) as ILogger<RemovalCommand>;
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;
}
}
+28
View File
@@ -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<object> func)
{
ArgumentNullException.ThrowIfNull(func);
_builder.ConfigureServices((_, services) => services.AddSingleton(service, _ => func()));
}
}
+16
View File
@@ -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();
}
}
@@ -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<ILogger, Exception> RemovalCommandFailedMsg = LoggerMessage.Define(
LogLevel.Error,
new EventId(0, nameof(RemovalCommandFailed)),
"An error occurred while executing the command."
);
private static readonly Action<ILogger, string, long, Exception> TimeAndLogActionMsg = LoggerMessage.Define<string, long>(
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<Task> action)
{
var sw = new Stopwatch();
sw.Start();
await action();
sw.Stop();
TimeAndLogActionMsg(logger, message, sw.ElapsedMilliseconds, default!);
}
public static async Task<T> TimeAndLogActionAsync<T>(this ILogger logger, string message, Func<Task<T>> 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<T>(this ILogger logger, string message, Func<T> action)
{
var sw = new Stopwatch();
sw.Start();
var result = action();
sw.Stop();
TimeAndLogActionMsg(logger, message, sw.ElapsedMilliseconds, default!);
return result;
}
}
+41
View File
@@ -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<IResourceManager, ResourceManager>();
services.AddSingleton<ImageProcessor, ImageSharpProcessor>();
services.AddSingleton<IInferenceRunner, OnnxInferenceRunner>();
services.AddSingleton<IModelFactory, ModelFactory>();
})
.BuildApp()
.RunAsync(args);
Log.Information("Stopping {AppName}", appName);
}
catch (Exception ex)
{
Log.Fatal(ex, "Application terminated unexpectedly");
throw;
}
finally
{
await Log.CloseAndFlushAsync();
}
+8
View File
@@ -0,0 +1,8 @@
namespace BGR.Console.Removal;
internal interface IImage
{
int Width { get; }
int Height { get; }
Stream Data { get; }
}
@@ -0,0 +1,6 @@
namespace BGR.Console.Removal;
internal interface IInferenceRunner
{
ITensor<float> Run(byte[] model, ITensor<float> inputTensor);
}
+10
View File
@@ -0,0 +1,10 @@
namespace BGR.Console.Removal;
internal interface ITensor<T>
{
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<T> ToTensor();
}
+26
View File
@@ -0,0 +1,26 @@
namespace BGR.Console.Removal;
internal abstract class ImageProcessor
{
public abstract Task<IImage> LoadImageAsync(string path);
public abstract Task<ITensor<float>> CreateTensorInputAsync(Stream image, Model model);
public abstract Task<Stream> GenerateMaskAsync(ITensor<float> maskTensor, int width, int height);
public abstract Task<Stream> RemoveBackgroundAsync(Stream image, Stream mask);
public abstract Task SaveImageAsync(Stream image, string path);
protected static void WalkImage(int height, int width, Action<int, int> action)
{
for (var y = 0; y < height; y++)
{
for (var x = 0; x < width; x++)
{
action(x, y);
}
}
}
}
@@ -0,0 +1,132 @@
namespace BGR.Console.Removal.ImageSharp;
internal class ImageSharpProcessor : ImageProcessor
{
public override async Task<IImage> LoadImageAsync(string path)
{
var image = await Image.LoadAsync<Rgba32>(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<ITensor<float>> CreateTensorInputAsync(Stream image, Model model)
{
using var resized = await Image.LoadAsync<Rgba32>(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<Stream> GenerateMaskAsync(ITensor<float> maskTensor, int width, int height)
{
using var mask = new Image<Rgba32>(width, height);
using Image<Rgba32> 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<Stream> RemoveBackgroundAsync(Stream image, Stream mask)
{
image.Position = 0;
mask.Position = 0;
var imageWithBg = await Image.LoadAsync<Rgba32>(image);
var maskImage = await Image.LoadAsync<Rgba32>(mask);
using var imageWithBgRemoved = new Image<Rgba32>(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<Rgba32>(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));
}
}
@@ -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));
}
}
@@ -0,0 +1,6 @@
namespace BGR.Console.Removal.Models;
internal interface IModelFactory
{
Model Create(string resourceName);
}
@@ -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;
}
+44
View File
@@ -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;
}
}
@@ -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}")
};
}
}
@@ -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;
}
@@ -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;
}
@@ -0,0 +1,18 @@
namespace BGR.Console.Removal.Onnx;
internal class OnnxInferenceRunner : IInferenceRunner
{
public ITensor<float> Run(byte[] model, ITensor<float> inputTensor)
{
using var options = new SessionOptions() { LogSeverityLevel = OrtLoggingLevel.ORT_LOGGING_LEVEL_ERROR };
using var session = new InferenceSession(model, options);
var inputs = new List<NamedOnnxValue>()
{
NamedOnnxValue.CreateFromTensor(session.InputNames[0], inputTensor.ToTensor()),
};
var results = session.Run(inputs);
var outputTensor = results[0].AsTensor<float>();
return new OnnxTensor(outputTensor);
}
}
@@ -0,0 +1,51 @@
namespace BGR.Console.Removal.Onnx;
public class OnnxTensor : ITensor<float>
{
private readonly Tensor<float> _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<float>([batchSize, channels, height, width]);
}
public OnnxTensor(Tensor<float> 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<float> ToTensor()
{
return _tensor;
}
}
+139
View File
@@ -0,0 +1,139 @@
using System.Diagnostics;
namespace BGR.Console.Removal;
internal class RemovalCommand(
IModelFactory modelFactory,
ImageProcessor imageProcessor,
IInferenceRunner inferenceRunner,
IAnsiConsole console,
ILogger<RemovalCommand> logger
) : AsyncCommand<RemovalCommand.Settings>
{
private readonly IModelFactory _modelFactory = modelFactory;
private readonly ImageProcessor _imageProcessor = imageProcessor;
private readonly IInferenceRunner _inferenceRunner = inferenceRunner;
private readonly IAnsiConsole _console = console;
private readonly ILogger<RemovalCommand> _logger = logger;
public override async Task<int> 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<string, string> Models = new()
{
{ RmbgModel.Id, "rmbg.onnx" },
{ ModNetModel.Id, "modnet.onnx" },
{ U2NetModel.Id, "u2net.onnx" },
};
[CommandArgument(0, "<image>")]
[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");
}
}
}
@@ -0,0 +1,6 @@
namespace BGR.Console.Resources;
internal interface IResourceManager
{
Stream GetResource(string resourceName);
}
@@ -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.");
}
}
+28
View File
@@ -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;
+28
View File
@@ -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
+13
View File
@@ -0,0 +1,13 @@
<Project>
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings>
<Nullable>enable</Nullable>
<AnalysisLevel>latest</AnalysisLevel>
<AnalysisMode>All</AnalysisMode>
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
<CodeAnalysisTreatWarningsAsErrors>true</CodeAnalysisTreatWarningsAsErrors>
<EnforceCodeStyleInBuild>true</EnforceCodeStyleInBuild>
</PropertyGroup>
</Project>