diff --git a/.gitignore b/.gitignore index 813a591..7dc04d3 100644 --- a/.gitignore +++ b/.gitignore @@ -1,14 +1,4 @@ -t Byte-compiled / optimized / DLL files -deps.txt -archive -saver -*~ -styles -pngs -preds - -*.sw* -data +# Byte-compiled / optimized / DLL files __pycache__/ *.py[cod] *$py.class @@ -16,9 +6,39 @@ __pycache__/ # C extensions *.so +# Virtual environment +venv/ +ENV/ +env/ +.env +.venv + +# IDE / Editor specific +.vscode/ +.idea/ +*.project +.classpath +.c9/ +*.launch +.settings/ +*.sublime-workspace +*.sublime-project +*.swp +*.swo +.spyderproject +.ropeproject + +# Temporary files +*.tmp +*~ +.DS_Store +Thumbs.db +ehthumbs.db +Desktop.ini +$RECYCLE.BIN/ + # Distribution / packaging .Python -env/ build/ develop-eggs/ dist/ @@ -30,13 +50,15 @@ lib64/ parts/ sdist/ var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ *.egg-info/ .installed.cfg *.egg +MANIFEST # PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. *.manifest *.spec @@ -47,21 +69,26 @@ pip-delete-this-directory.txt # Unit test / coverage reports htmlcov/ .tox/ +.nox/ .coverage .coverage.* .cache nosetests.xml coverage.xml -*,cover +*.cover +*.py,cover .hypothesis/ +.pytest_cache/ # Translations *.mo *.pot # Django stuff: -*.log -local_settings.py +# *.log (covered by *.log below) +# local_settings.py (can be project specific) +db.sqlite3 +db.sqlite3-journal # Flask stuff: instance/ @@ -76,24 +103,36 @@ docs/_build/ # PyBuilder target/ -# IPython Notebook +# Jupyter Notebook .ipynb_checkpoints +# IPython +profile_default/ +ipython_config.py + # pyenv .python-version -# celery beat schedule file +# PEP 582; __pypackages__ directory +__pypackages__/ + +# Celery stuff celerybeat-schedule +celerybeat.pid -# dotenv -.env +# SageMath files +.sage/ -# virtualenv -venv/ -ENV/ +# General logs +*.log -# Spyder project settings -.spyderproject +# Application-specific +# Ignore uploaded and processed images, but keep the directories via .gitkeep +denoise_app/uploads/* +!denoise_app/uploads/.gitkeep +denoise_app/denoised/* +!denoise_app/denoised/.gitkeep -# Rope project settings -.ropeproject +# Test artifacts from this project +tests/test_temp_uploads/ +tests/test_temp_denoised/ diff --git a/denoise_app/app.py b/denoise_app/app.py new file mode 100644 index 0000000..12608c6 --- /dev/null +++ b/denoise_app/app.py @@ -0,0 +1,65 @@ +import os +from flask import Flask, request, render_template, redirect, url_for, send_from_directory +from werkzeug.utils import secure_filename +from denoising import denoise_image # Assuming denoising.py is in the same directory + +app = Flask(__name__) + +# Configure upload and denoised folders +APP_ROOT = os.path.dirname(os.path.abspath(__file__)) +UPLOAD_FOLDER = os.path.join(APP_ROOT, 'uploads') +DENOISED_FOLDER = os.path.join(APP_ROOT, 'denoised') +app.config['UPLOAD_FOLDER'] = UPLOAD_FOLDER +app.config['DENOISED_FOLDER'] = DENOISED_FOLDER + +# Ensure directories exist +os.makedirs(UPLOAD_FOLDER, exist_ok=True) +os.makedirs(DENOISED_FOLDER, exist_ok=True) + +ALLOWED_EXTENSIONS = {'png', 'jpg', 'jpeg', 'gif'} + +def allowed_file(filename): + return '.' in filename and \ + filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS + +@app.route('/') +def index(): + return render_template('index.html') + +@app.route('/upload', methods=['POST']) +def upload_file(): + if 'image' not in request.files: + return redirect(request.url) # Should be redirect('/') or url_for('index') + + file = request.files['image'] + if file.filename == '': + return redirect(request.url) # Should be redirect('/') or url_for('index') + + if file and allowed_file(file.filename): + filename = secure_filename(file.filename) + uploaded_image_path = os.path.join(app.config['UPLOAD_FOLDER'], filename) + file.save(uploaded_image_path) + + denoised_image_output_path = denoise_image(uploaded_image_path, app.config['DENOISED_FOLDER']) + + if denoised_image_output_path: + denoised_filename = os.path.basename(denoised_image_output_path) + original_image_url = url_for('serve_uploaded_file', filename=filename) + denoised_image_url = url_for('serve_denoised_file', filename=denoised_filename) + return render_template('result.html', original_image_url=original_image_url, denoised_image_url=denoised_image_url) + else: + # Handle denoising failure, e.g., redirect to index or show an error + return redirect(url_for('index')) # Or a specific error page + + return redirect(url_for('index')) # If file type not allowed or other issues + +@app.route('/uploads/') +def serve_uploaded_file(filename): + return send_from_directory(app.config['UPLOAD_FOLDER'], filename) + +@app.route('/denoised/') +def serve_denoised_file(filename): + return send_from_directory(app.config['DENOISED_FOLDER'], filename) + +if __name__ == '__main__': + app.run(debug=True) diff --git a/denoise_app/denoised/.gitkeep b/denoise_app/denoised/.gitkeep new file mode 100644 index 0000000..f003fa2 --- /dev/null +++ b/denoise_app/denoised/.gitkeep @@ -0,0 +1 @@ +# This file is to ensure the directory is tracked by git. diff --git a/denoise_app/denoising.py b/denoise_app/denoising.py new file mode 100644 index 0000000..dcff491 --- /dev/null +++ b/denoise_app/denoising.py @@ -0,0 +1,73 @@ +import cv2 +import os +import numpy as np + +def denoise_image(input_image_path, output_directory): + """ + Reads an image, applies a denoising algorithm, and saves the denoised image. + + Args: + input_image_path (str): The path to the input image. + output_directory (str): The directory to save the denoised image. + + Returns: + str: The full path to the saved denoised image, or None if an error occurs. + """ + if not os.path.exists(input_image_path): + print(f"Error: Input image path does not exist: {input_image_path}") + return None + + img = cv2.imread(input_image_path) + if img is None: + print(f"Error: Could not read image from path: {input_image_path}") + return None + + try: + denoised_img = cv2.fastNlMeansDenoisingColored(img, None, h=10, hColor=10, templateWindowSize=7, searchWindowSize=21) + + if not os.path.exists(output_directory): + os.makedirs(output_directory) + + base_filename = os.path.basename(input_image_path) + denoised_filename = f"denoised_{base_filename}" + output_image_path = os.path.join(output_directory, denoised_filename) + + cv2.imwrite(output_image_path, denoised_img) + return output_image_path + + except Exception as e: + print(f"Error during denoising or saving image: {e}") + return None + +if __name__ == '__main__': + # Create dummy files for testing + # This part is for basic testing and might need adjustment based on project structure + if not os.path.exists("test_images"): + os.makedirs("test_images") + if not os.path.exists("test_output"): + os.makedirs("test_output") + + # Create a dummy image (e.g., a black square) + dummy_image = np.zeros((100, 100, 3), dtype=np.uint8) + dummy_input_path = "test_images/dummy_input.png" + cv2.imwrite(dummy_input_path, dummy_image) + + print(f"Attempting to denoise: {dummy_input_path}") + denoised_path = denoise_image(dummy_input_path, "test_output") + + if denoised_path: + print(f"Denoised image saved to: {denoised_path}") + # Clean up dummy files + os.remove(dummy_input_path) + os.remove(denoised_path) + os.rmdir("test_images") + os.rmdir("test_output") + else: + print("Denoising failed.") + # Clean up dummy input if it exists + if os.path.exists(dummy_input_path): + os.remove(dummy_input_path) + if os.path.exists("test_images"): + os.rmdir("test_images") + if os.path.exists("test_output"): + os.rmdir("test_output") diff --git a/denoise_app/static/.gitkeep b/denoise_app/static/.gitkeep new file mode 100644 index 0000000..f003fa2 --- /dev/null +++ b/denoise_app/static/.gitkeep @@ -0,0 +1 @@ +# This file is to ensure the directory is tracked by git. diff --git a/denoise_app/static/css/style.css b/denoise_app/static/css/style.css new file mode 100644 index 0000000..3406e78 --- /dev/null +++ b/denoise_app/static/css/style.css @@ -0,0 +1,115 @@ +/* Basic body styling */ +body { + font-family: Arial, sans-serif; + margin: 20px; + padding: 0; + background-color: #f4f4f4; + color: #333; + line-height: 1.6; +} + +h1, h2 { + color: #333; + text-align: center; +} + +/* Container for the main content */ +.container { + width: 80%; + margin: auto; + overflow: hidden; + padding: 20px; + background: #fff; + box-shadow: 0 0 10px rgba(0, 0, 0, 0.1); +} + +/* Styling for the upload form */ +form { + display: flex; + flex-direction: column; + align-items: center; + margin-top: 20px; + padding: 20px; + background: #fff; + border-radius: 8px; +} + +form label { + font-size: 1.2em; + margin-bottom: 10px; +} + +input[type="file"] { + margin-bottom: 20px; + padding: 10px; + border: 1px solid #ddd; + border-radius: 4px; +} + +input[type="submit"] { + background-color: #5cb85c; + color: white; + padding: 10px 20px; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 1em; +} + +input[type="submit"]:hover { + background-color: #4cae4c; +} + +/* Styling for the results page */ +.result-container { + display: flex; + flex-direction: column; /* Stack items vertically on smaller screens */ + align-items: center; /* Center items when stacked */ + margin-top: 20px; +} + +.image-display-area { + display: flex; + justify-content: space-around; /* Space out images */ + flex-wrap: wrap; /* Allow wrapping on smaller screens */ + width: 100%; +} + +.image-item { + text-align: center; + margin: 10px; /* Add some margin around items */ + padding: 15px; + background: #fff; + border: 1px solid #ddd; + box-shadow: 0 0 5px rgba(0,0,0,0.05); + border-radius: 8px; +} + +.image-item h2 { + font-size: 1.5em; + margin-bottom: 10px; +} + +.image-item img { + max-width: 100%; /* Make images responsive within their container */ + height: auto; /* Maintain aspect ratio */ + max-height: 450px; /* Limit max height */ + border: 2px solid #ccc; + border-radius: 4px; + display: block; /* Remove extra space below image */ + margin: 0 auto; /* Center image if it's smaller than container */ +} + +/* Link to go back */ +a { + display: block; + text-align: center; + margin-top: 30px; + color: #5cb85c; + text-decoration: none; + font-size: 1.1em; +} + +a:hover { + text-decoration: underline; +} diff --git a/denoise_app/templates/.gitkeep b/denoise_app/templates/.gitkeep new file mode 100644 index 0000000..f003fa2 --- /dev/null +++ b/denoise_app/templates/.gitkeep @@ -0,0 +1 @@ +# This file is to ensure the directory is tracked by git. diff --git a/denoise_app/templates/index.html b/denoise_app/templates/index.html new file mode 100644 index 0000000..5d882a6 --- /dev/null +++ b/denoise_app/templates/index.html @@ -0,0 +1,19 @@ + + + + + + Image Denoising App + + + +
+

Image Denoising App

+
+

+ + +
+
+ + diff --git a/denoise_app/templates/result.html b/denoise_app/templates/result.html new file mode 100644 index 0000000..028eae8 --- /dev/null +++ b/denoise_app/templates/result.html @@ -0,0 +1,29 @@ + + + + + + Denoising Result + + + +
+

Denoising Result

+ +
+
+
+

Original Image

+ Original Image +
+
+

Denoised Image

+ Denoised Image +
+
+
+ + Upload another image +
+ + diff --git a/denoise_app/uploads/.gitkeep b/denoise_app/uploads/.gitkeep new file mode 100644 index 0000000..f003fa2 --- /dev/null +++ b/denoise_app/uploads/.gitkeep @@ -0,0 +1 @@ +# This file is to ensure the directory is tracked by git. diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a627284 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,3 @@ +Flask +opencv-python +numpy diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..527111f --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# This file makes Python treat the 'tests' directory as a package. diff --git a/tests/test_app.py b/tests/test_app.py new file mode 100644 index 0000000..faa7f04 --- /dev/null +++ b/tests/test_app.py @@ -0,0 +1,123 @@ +import unittest +import os +import shutil +import tempfile +from denoise_app.app import app # Import the Flask app instance +from io import BytesIO # For creating dummy file uploads + +class TestWebApp(unittest.TestCase): + + def setUp(self): + app.config['TESTING'] = True + self.client = app.test_client() + + # Create temporary folders for uploads and denoised images for the app context + self.temp_upload_dir = tempfile.mkdtemp() + self.temp_denoised_dir = tempfile.mkdtemp() + + app.config['UPLOAD_FOLDER'] = self.temp_upload_dir + app.config['DENOISED_FOLDER'] = self.temp_denoised_dir + + # Ensure these directories exist for the app during tests + os.makedirs(app.config['UPLOAD_FOLDER'], exist_ok=True) + os.makedirs(app.config['DENOISED_FOLDER'], exist_ok=True) + + # Create a dummy image file for upload testing + self.dummy_image_name = "test_image.png" + self.dummy_image_path = os.path.join(self.temp_upload_dir, self.dummy_image_name) + + # Create a simple PNG file (1x1 pixel) + try: + from PIL import Image + img = Image.new('RGB', (1, 1), color = 'red') + img.save(self.dummy_image_path, "PNG") + self.pil_available = True + except ImportError: + self.pil_available = False + # Fallback: create a dummy text file if PIL is not available, + # and skip tests that require actual image processing by the app. + # For a real scenario, ensure PIL or OpenCV is in test requirements. + with open(self.dummy_image_path, "wb") as f: # Write as bytes + f.write(b"dummy png content") + print("Warning: PIL not found. Upload test will use a dummy file. Some checks might be lenient.") + + + def tearDown(self): + if os.path.exists(self.temp_upload_dir): + shutil.rmtree(self.temp_upload_dir) + if os.path.exists(self.temp_denoised_dir): + shutil.rmtree(self.temp_denoised_dir) + + def test_index_page(self): + response = self.client.get('/') + self.assertEqual(response.status_code, 200) + self.assertIn(b"Image Denoising App", response.data) + self.assertIn(b"Upload and Denoise", response.data) + + def test_upload_no_file(self): + response = self.client.post('/upload', data={}) + self.assertEqual(response.status_code, 302) # Expecting a redirect + self.assertIn(b'/', response.location.encode()) # Check if redirects to index + + def test_upload_and_denoise_success(self): + if not self.pil_available and not os.path.exists(self.dummy_image_path): + self.skipTest("PIL not available and dummy image creation failed, skipping upload success test.") + + # If you want to truly mock denoise_image, you would use unittest.mock.patch + # from unittest.mock import patch + # @patch('denoise_app.app.denoise_image') + # def test_upload_and_denoise_success(self, mock_denoise_image): + # mock_denoise_image.return_value = os.path.join(app.config['DENOISED_FOLDER'], "denoised_test_image.png") + # Create a dummy denoised file as if denoise_image function created it + # if not os.path.exists(os.path.join(app.config['DENOISED_FOLDER'], "denoised_test_image.png")): + # with open(os.path.join(app.config['DENOISED_FOLDER'], "denoised_test_image.png"), "w") as f: + # f.write("dummy denoised content") + + data = {} + try: + with open(self.dummy_image_path, 'rb') as img_file: + data['image'] = (BytesIO(img_file.read()), self.dummy_image_name) + except FileNotFoundError: + self.skipTest(f"Dummy image {self.dummy_image_path} not found, skipping upload success test.") + + if not data: # If file could not be opened + self.skipTest("Dummy image could not be prepared for upload.") + + response = self.client.post('/upload', data=data, content_type='multipart/form-data') + + self.assertEqual(response.status_code, 200, f"Upload failed with status {response.status_code}. Response data: {response.data.decode()}") + self.assertIn(b"Original Image", response.data) + self.assertIn(b"Denoised Image", response.data) + + # Check if uploaded file exists + uploaded_files = os.listdir(app.config['UPLOAD_FOLDER']) + self.assertTrue(any(f.startswith(self.dummy_image_name.split('.')[0]) for f in uploaded_files), "Uploaded file not found in upload folder.") + + # Check if denoised file exists (name will be like 'denoised_test_image.png') + denoised_files = os.listdir(app.config['DENOISED_FOLDER']) + self.assertTrue(any(f.startswith("denoised_") for f in denoised_files), "Denoised file not found in denoised folder.") + + + def test_serve_uploaded_file(self): + # Ensure there's a file to serve + test_serve_filename = "serve_me.txt" + with open(os.path.join(app.config['UPLOAD_FOLDER'], test_serve_filename), 'w') as f: + f.write("Test content for serving.") + + response = self.client.get(f'/uploads/{test_serve_filename}') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data, b"Test content for serving.") + + def test_serve_denoised_file(self): + # Ensure there's a file to serve + test_serve_filename = "denoised_serve_me.txt" + with open(os.path.join(app.config['DENOISED_FOLDER'], test_serve_filename), 'w') as f: + f.write("Test denoised content for serving.") + + response = self.client.get(f'/denoised/{test_serve_filename}') + self.assertEqual(response.status_code, 200) + self.assertEqual(response.data, b"Test denoised content for serving.") + + +if __name__ == '__main__': + unittest.main() diff --git a/tests/test_denoising.py b/tests/test_denoising.py new file mode 100644 index 0000000..bb40755 --- /dev/null +++ b/tests/test_denoising.py @@ -0,0 +1,60 @@ +import unittest +import cv2 +import numpy as np +import os +import shutil +from denoise_app.denoising import denoise_image + +class TestDenoising(unittest.TestCase): + + def setUp(self): + self.test_uploads_dir = "test_temp_uploads" + self.test_denoised_dir = "test_temp_denoised" + os.makedirs(self.test_uploads_dir, exist_ok=True) + os.makedirs(self.test_denoised_dir, exist_ok=True) + + # Create a dummy noisy image + self.dummy_image_name = "noisy_image.png" + self.dummy_image_path = os.path.join(self.test_uploads_dir, self.dummy_image_name) + + # Create a simple image (e.g., 100x100 with random noise) + height, width = 100, 100 + noisy_data = np.random.randint(0, 256, (height, width, 3), dtype=np.uint8) + cv2.imwrite(self.dummy_image_path, noisy_data) + + # Create a non-image file for testing invalid image scenario + self.invalid_file_name = "invalid_file.txt" + self.invalid_file_path = os.path.join(self.test_uploads_dir, self.invalid_file_name) + with open(self.invalid_file_path, "w") as f: + f.write("This is not an image.") + + def tearDown(self): + if os.path.exists(self.test_uploads_dir): + shutil.rmtree(self.test_uploads_dir) + if os.path.exists(self.test_denoised_dir): + shutil.rmtree(self.test_denoised_dir) + + def test_denoise_image_success(self): + denoised_path = denoise_image(self.dummy_image_path, self.test_denoised_dir) + + self.assertIsNotNone(denoised_path, "Denoising function returned None for a valid image.") + self.assertTrue(os.path.exists(denoised_path), "Denoised image file does not exist.") + + denoised_img = cv2.imread(denoised_path) + self.assertIsNotNone(denoised_img, "Denoised image could not be read by OpenCV.") + self.assertTrue(denoised_img.size > 0, "Denoised image is empty.") + + original_img = cv2.imread(self.dummy_image_path) + self.assertEqual(original_img.shape, denoised_img.shape, "Original and denoised images have different dimensions.") + + def test_denoise_image_file_not_found(self): + non_existent_path = os.path.join(self.test_uploads_dir, "non_existent_image.png") + result = denoise_image(non_existent_path, self.test_denoised_dir) + self.assertIsNone(result, "Denoising function did not return None for a non-existent file.") + + def test_denoise_image_invalid_image(self): + result = denoise_image(self.invalid_file_path, self.test_denoised_dir) + self.assertIsNone(result, "Denoising function did not return None for an invalid image file.") + +if __name__ == '__main__': + unittest.main()