Add jpegtran lossless rotation and EXIF orientation handling

DCT steganography improvements:
- Add _apply_exif_orientation() to fix portrait photos encoding rotated
- Add _jpegtran_rotate() for lossless JPEG rotation preserving DCT data
- Add rotation fallback in extract_from_dct() - tries 0°, 90°, 180°, 270°
- Quick header validation to skip invalid rotations efficiently
- Fix: wrap debug.print in try/except to prevent extraction failures

Web UI rotate tool:
- Use jpegtran for JPEGs (lossless, preserves DCT steganography)
- Fall back to PIL for non-JPEGs
- Dynamic UI shows "DCT Safe" for JPEGs, warning for other formats

This enables the workflow: encode → compress → rotate → decode
Rotated stego JPEGs can now be decoded by trying all orientations.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Aaron D. Lee
2026-01-11 16:36:52 -05:00
parent 2ebc42f2cd
commit 4e3acfca20
4 changed files with 477 additions and 36 deletions

29
TODO-4.2.1.md Normal file
View File

@@ -0,0 +1,29 @@
# Stegasoo 4.2.1 Plan
## Bugs
- [ ] Fix EXIF viewer panel not loading metadata in Web UI
- [x] DCT mode: portrait photos export rotated 90° (EXIF orientation not handled)
- Added `_apply_exif_orientation()` to apply EXIF rotation before embedding
- [x] DCT mode: add rotation fallback (try as-is, rotate 90°, retry on failure)
- Added rotation fallback in `extract_from_dct()` with quick header validation
- [x] Rotate tool: use jpegtran for lossless JPEG rotation (preserves DCT stego!)
- Web UI rotate tool now uses jpegtran for JPEGs
- DCT decode rotation fallback now uses jpegtran for JPEGs
- Dynamic UI shows "DCT Safe" for JPEGs, warning for other formats
## Tools Audit
- [ ] Web UI tools - full shakedown and fixes
- [ ] CLI tools - full shakedown and fixes
## AUR Packages
- [ ] `stegasoo-cli` - standalone CLI package (no web dependencies)
- [ ] `stegasoo-api` - REST API package (needs auth overhaul first)
## API Auth Work (blocking stegasoo-api)
- [ ] Implement OAuth2 authentication
- [ ] TLS 1.3 support with self-signed certificates
- [ ] Figure out cert trust/distribution for clients
## API Documentation
- [ ] Postman collection
- [ ] Environment variable templates

View File

@@ -2100,8 +2100,11 @@ def api_tools_exif_clear():
@app.route("/api/tools/rotate", methods=["POST"]) @app.route("/api/tools/rotate", methods=["POST"])
@login_required @login_required
def api_tools_rotate(): def api_tools_rotate():
"""Rotate and/or flip an image.""" """Rotate and/or flip an image, using lossless jpegtran for JPEGs."""
from PIL import Image from PIL import Image
import shutil
import subprocess
import tempfile
image_file = request.files.get("image") image_file = request.files.get("image")
if not image_file: if not image_file:
@@ -2112,7 +2115,91 @@ def api_tools_rotate():
flip_v = request.form.get("flip_v", "false").lower() == "true" flip_v = request.form.get("flip_v", "false").lower() == "true"
try: try:
img = Image.open(io.BytesIO(image_file.read())) image_data = image_file.read()
img = Image.open(io.BytesIO(image_data))
original_format = img.format # JPEG, PNG, etc.
img.close()
# For JPEGs, use jpegtran for lossless rotation/flip (preserves DCT stego)
has_jpegtran = shutil.which("jpegtran") is not None
use_jpegtran = original_format == "JPEG" and has_jpegtran and (rotation or flip_h or flip_v)
if use_jpegtran:
# Chain jpegtran operations for lossless transformation
current_data = image_data
# Apply rotation first
if rotation in (90, 180, 270):
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
f.write(current_data)
input_path = f.name
output_path = tempfile.mktemp(suffix=".jpg")
try:
result = subprocess.run(
["jpegtran", "-rotate", str(rotation), "-copy", "all", "-trim",
"-outfile", output_path, input_path],
capture_output=True, timeout=30
)
if result.returncode == 0:
with open(output_path, "rb") as f:
current_data = f.read()
finally:
for p in [input_path, output_path]:
try:
os.unlink(p)
except OSError:
pass
# Apply horizontal flip
if flip_h:
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
f.write(current_data)
input_path = f.name
output_path = tempfile.mktemp(suffix=".jpg")
try:
result = subprocess.run(
["jpegtran", "-flip", "horizontal", "-copy", "all", "-trim",
"-outfile", output_path, input_path],
capture_output=True, timeout=30
)
if result.returncode == 0:
with open(output_path, "rb") as f:
current_data = f.read()
finally:
for p in [input_path, output_path]:
try:
os.unlink(p)
except OSError:
pass
# Apply vertical flip
if flip_v:
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
f.write(current_data)
input_path = f.name
output_path = tempfile.mktemp(suffix=".jpg")
try:
result = subprocess.run(
["jpegtran", "-flip", "vertical", "-copy", "all", "-trim",
"-outfile", output_path, input_path],
capture_output=True, timeout=30
)
if result.returncode == 0:
with open(output_path, "rb") as f:
current_data = f.read()
finally:
for p in [input_path, output_path]:
try:
os.unlink(p)
except OSError:
pass
buffer = io.BytesIO(current_data)
mimetype = "image/jpeg"
ext = "jpg"
else:
# Fallback to PIL for non-JPEGs or when jpegtran unavailable
img = Image.open(io.BytesIO(image_data))
# Apply rotation (PIL rotates counter-clockwise, so negate) # Apply rotation (PIL rotates counter-clockwise, so negate)
if rotation: if rotation:
@@ -2124,9 +2211,18 @@ def api_tools_rotate():
if flip_v: if flip_v:
img = img.transpose(Image.FLIP_TOP_BOTTOM) img = img.transpose(Image.FLIP_TOP_BOTTOM)
# Output as PNG (lossless) # Preserve original format
buffer = io.BytesIO() buffer = io.BytesIO()
if original_format == "JPEG":
if img.mode in ("RGBA", "P"):
img = img.convert("RGB")
img.save(buffer, format="JPEG", quality=95)
mimetype = "image/jpeg"
ext = "jpg"
else:
img.save(buffer, format="PNG") img.save(buffer, format="PNG")
mimetype = "image/png"
ext = "png"
buffer.seek(0) buffer.seek(0)
stem = ( stem = (
@@ -2136,9 +2232,9 @@ def api_tools_rotate():
) )
return send_file( return send_file(
buffer, buffer,
mimetype="image/png", mimetype=mimetype,
as_attachment=True, as_attachment=True,
download_name=f"{stem}_transformed.png", download_name=f"{stem}_transformed.{ext}",
) )
except Exception as e: except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500 return jsonify({"success": False, "error": str(e)}), 500

View File

@@ -22,17 +22,17 @@
<div class="tools-ribbon-divider"></div> <div class="tools-ribbon-divider"></div>
<div class="tools-ribbon-group"> <div class="tools-ribbon-group">
<button class="tool-icon-btn" data-tool="strip" title="Strip Metadata"> <button class="tool-icon-btn" data-tool="compress" title="JPEG Compression">
<i class="bi bi-eraser"></i> <i class="bi bi-file-zip"></i>
<span>Strip</span> <span>Compress</span>
</button> </button>
<button class="tool-icon-btn" data-tool="rotate" title="Rotate / Flip"> <button class="tool-icon-btn" data-tool="rotate" title="Rotate / Flip">
<i class="bi bi-arrow-repeat"></i> <i class="bi bi-arrow-repeat"></i>
<span>Rotate</span> <span>Rotate</span>
</button> </button>
<button class="tool-icon-btn" data-tool="compress" title="JPEG Compression"> <button class="tool-icon-btn" data-tool="strip" title="Strip Metadata">
<i class="bi bi-file-zip"></i> <i class="bi bi-eraser"></i>
<span>Compress</span> <span>Strip</span>
</button> </button>
<button class="tool-icon-btn" data-tool="convert" title="Format Convert"> <button class="tool-icon-btn" data-tool="convert" title="Format Convert">
<i class="bi bi-arrow-left-right"></i> <i class="bi bi-arrow-left-right"></i>
@@ -368,6 +368,14 @@
<span class="tool-result-label">Flipped</span> <span class="tool-result-label">Flipped</span>
<span class="tool-result-value" id="rotateFlip">None</span> <span class="tool-result-value" id="rotateFlip">None</span>
</div> </div>
<div class="alert alert-success small mt-3 mb-0" id="rotateJpegSafe" style="display: none;">
<i class="bi bi-check-circle me-1"></i>
<strong>DCT Safe:</strong> Uses jpegtran for lossless JPEG rotation. Your stego data will be preserved.
</div>
<div class="alert alert-warning small mt-3 mb-0" id="rotateNonJpegWarn" style="display: none;">
<i class="bi bi-exclamation-triangle me-1"></i>
<strong>Note:</strong> Non-JPEG images are re-encoded during rotation.
</div>
</div> </div>
</div> </div>
<div class="tool-results-actions d-none" id="rotateActions"> <div class="tool-results-actions d-none" id="rotateActions">
@@ -634,6 +642,18 @@ setupDropZone('exifZone', 'exifFile', async (file) => {
try { try {
const res = await fetch('/api/tools/exif', { method: 'POST', body: formData }); const res = await fetch('/api/tools/exif', { method: 'POST', body: formData });
// Check for auth redirect or non-JSON response
const contentType = res.headers.get('content-type') || '';
if (!contentType.includes('application/json')) {
console.error('EXIF API returned non-JSON:', res.status, contentType);
document.getElementById('exifNoData').classList.remove('d-none');
document.getElementById('exifNoData').innerHTML = '<i class="bi bi-exclamation-triangle d-block mb-2"></i>Session expired - please refresh';
document.getElementById('exifEmpty').classList.add('d-none');
document.getElementById('exifData').classList.remove('d-none');
return;
}
const data = await res.json(); const data = await res.json();
if (data.success) { if (data.success) {
@@ -643,6 +663,7 @@ setupDropZone('exifZone', 'exifFile', async (file) => {
if (entries.length === 0) { if (entries.length === 0) {
tbody.innerHTML = ''; tbody.innerHTML = '';
document.getElementById('exifNoData').classList.remove('d-none'); document.getElementById('exifNoData').classList.remove('d-none');
document.getElementById('exifNoData').innerHTML = '<i class="bi bi-inbox d-block mb-2"></i>No metadata found';
} else { } else {
document.getElementById('exifNoData').classList.add('d-none'); document.getElementById('exifNoData').classList.add('d-none');
tbody.innerHTML = entries.map(([key, value]) => { tbody.innerHTML = entries.map(([key, value]) => {
@@ -655,9 +676,20 @@ setupDropZone('exifZone', 'exifFile', async (file) => {
document.getElementById('exifEmpty').classList.add('d-none'); document.getElementById('exifEmpty').classList.add('d-none');
document.getElementById('exifData').classList.remove('d-none'); document.getElementById('exifData').classList.remove('d-none');
document.getElementById('exifActions').classList.remove('d-none'); document.getElementById('exifActions').classList.remove('d-none');
} else {
// API returned success: false
console.error('EXIF API error:', data.error);
document.getElementById('exifNoData').classList.remove('d-none');
document.getElementById('exifNoData').innerHTML = `<i class="bi bi-exclamation-triangle d-block mb-2"></i>${data.error || 'Error reading metadata'}`;
document.getElementById('exifEmpty').classList.add('d-none');
document.getElementById('exifData').classList.remove('d-none');
} }
} catch (err) { } catch (err) {
console.error(err); console.error('EXIF fetch error:', err);
document.getElementById('exifNoData').classList.remove('d-none');
document.getElementById('exifNoData').innerHTML = '<i class="bi bi-exclamation-triangle d-block mb-2"></i>Error loading metadata';
document.getElementById('exifEmpty').classList.add('d-none');
document.getElementById('exifData').classList.remove('d-none');
} }
}); });
@@ -796,6 +828,11 @@ setupDropZone('rotateZone', 'rotateFile', async (file) => {
document.getElementById('rotateData').classList.remove('d-none'); document.getElementById('rotateData').classList.remove('d-none');
document.getElementById('rotateActions').classList.remove('d-none'); document.getElementById('rotateActions').classList.remove('d-none');
// Show appropriate DCT warning based on file type
const isJpeg = file.type === 'image/jpeg' || file.name.toLowerCase().match(/\.jpe?g$/);
document.getElementById('rotateJpegSafe').style.display = isJpeg ? 'block' : 'none';
document.getElementById('rotateNonJpegWarn').style.display = isJpeg ? 'none' : 'block';
// Load image to get dimensions, then show preview // Load image to get dimensions, then show preview
const thumb = document.getElementById('rotateThumb'); const thumb = document.getElementById('rotateThumb');
const objectUrl = URL.createObjectURL(file); const objectUrl = URL.createObjectURL(file);
@@ -889,6 +926,8 @@ function clearRotate() {
document.getElementById('rotateData').classList.add('d-none'); document.getElementById('rotateData').classList.add('d-none');
document.getElementById('rotateActions').classList.add('d-none'); document.getElementById('rotateActions').classList.add('d-none');
document.getElementById('rotateFileInfo').classList.add('d-none'); document.getElementById('rotateFileInfo').classList.add('d-none');
document.getElementById('rotateJpegSafe').style.display = 'none';
document.getElementById('rotateNonJpegWarn').style.display = 'none';
const thumb = document.getElementById('rotateThumb'); const thumb = document.getElementById('rotateThumb');
thumb.style.transform = ''; thumb.style.transform = '';
thumb.style.width = ''; thumb.style.width = '';
@@ -920,8 +959,7 @@ document.getElementById('rotateDownload')?.addEventListener('click', async funct
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
const baseName = rotateCurrentFile?.name?.replace(/\.[^.]+$/, '') || 'rotated'; a.download = res.headers.get('Content-Disposition')?.split('filename=')[1]?.replace(/"/g, '') || 'rotated.jpg';
a.download = `${baseName}_transformed.png`;
document.body.appendChild(a); document.body.appendChild(a);
a.click(); a.click();
document.body.removeChild(a); document.body.removeChild(a);

View File

@@ -35,7 +35,7 @@ from dataclasses import dataclass
from enum import Enum from enum import Enum
import numpy as np import numpy as np
from PIL import Image from PIL import Image, ImageOps
# Check for scipy availability (for PNG/DCT mode) # Check for scipy availability (for PNG/DCT mode)
# Prefer scipy.fft (newer, more stable) over scipy.fftpack # Prefer scipy.fft (newer, more stable) over scipy.fftpack
@@ -406,6 +406,45 @@ def _safe_idct2(block: np.ndarray) -> np.ndarray:
# ============================================================================ # ============================================================================
def _apply_exif_orientation(image_data: bytes) -> bytes:
"""
Apply EXIF orientation to image and return corrected bytes.
Portrait photos from cameras often have EXIF orientation metadata that
tells viewers to rotate the image for display. However, the raw pixel
data is stored in landscape orientation. This function applies that
rotation to the pixel data so the output matches what users expect.
Without this, a portrait photo encoded with DCT would come out rotated
90 degrees because we'd embed in the raw (landscape) orientation.
"""
img = Image.open(io.BytesIO(image_data))
original_format = img.format or "JPEG"
# Apply EXIF orientation (rotates/flips pixels to match EXIF tag)
# This also removes the EXIF orientation tag since it's now baked in
corrected = ImageOps.exif_transpose(img)
# If no change was needed, return original data unchanged
if corrected is img:
img.close()
return image_data
# Save corrected image back to bytes
output = io.BytesIO()
if original_format == "JPEG":
if corrected.mode in ("RGBA", "P"):
corrected = corrected.convert("RGB")
corrected.save(output, format="JPEG", quality=95)
else:
corrected.save(output, format="PNG")
img.close()
corrected.close()
output.seek(0)
return output.getvalue()
def _to_grayscale(image_data: bytes) -> np.ndarray: def _to_grayscale(image_data: bytes) -> np.ndarray:
img = Image.open(io.BytesIO(image_data)) img = Image.open(io.BytesIO(image_data))
gray = img.convert("L") gray = img.convert("L")
@@ -763,6 +802,10 @@ def embed_in_dct(
if color_mode not in ("color", "grayscale"): if color_mode not in ("color", "grayscale"):
color_mode = "color" color_mode = "color"
# Apply EXIF orientation to carrier image before embedding
# This ensures portrait photos are embedded in their correct visual orientation
carrier_image = _apply_exif_orientation(carrier_image)
if output_format == OUTPUT_FORMAT_JPEG and HAS_JPEGIO: if output_format == OUTPUT_FORMAT_JPEG and HAS_JPEGIO:
return _embed_jpegio(data, carrier_image, seed, color_mode, progress_file) return _embed_jpegio(data, carrier_image, seed, color_mode, progress_file)
@@ -1173,24 +1216,259 @@ def _embed_jpegio(
pass pass
def _jpegtran_available() -> bool:
"""Check if jpegtran is available on the system."""
import shutil
return shutil.which("jpegtran") is not None
def _jpegtran_rotate(image_data: bytes, rotation: int) -> bytes:
"""
Losslessly rotate a JPEG using jpegtran.
This preserves DCT coefficients by rearranging blocks rather than
re-encoding. Essential for rotating stego images without destroying
the hidden data.
Args:
image_data: JPEG image bytes
rotation: Degrees clockwise (90, 180, or 270)
Returns:
Rotated JPEG bytes with DCT coefficients preserved
"""
import subprocess
import tempfile
import os
if rotation not in (90, 180, 270):
raise ValueError(f"Invalid rotation: {rotation}")
# Write input to temp file
with tempfile.NamedTemporaryFile(suffix=".jpg", delete=False) as f:
f.write(image_data)
input_path = f.name
output_path = tempfile.mktemp(suffix=".jpg")
try:
# jpegtran -rotate 90|180|270 -copy all -perfect
# -copy all: preserve all metadata
# -perfect: fail if there are non-transformable edge blocks (rare)
result = subprocess.run(
["jpegtran", "-rotate", str(rotation), "-copy", "all", "-perfect",
"-outfile", output_path, input_path],
capture_output=True,
timeout=30
)
# If -perfect fails (edge blocks), retry without it
if result.returncode != 0:
result = subprocess.run(
["jpegtran", "-rotate", str(rotation), "-copy", "all", "-trim",
"-outfile", output_path, input_path],
capture_output=True,
timeout=30
)
if result.returncode != 0:
raise RuntimeError(f"jpegtran failed: {result.stderr.decode()}")
with open(output_path, "rb") as f:
return f.read()
finally:
for path in [input_path, output_path]:
try:
os.unlink(path)
except OSError:
pass
def _rotate_image_bytes(image_data: bytes, rotation: int, lossless: bool = True) -> bytes:
"""
Rotate image by 90, 180, or 270 degrees and return as bytes.
For JPEGs with lossless=True (default), uses jpegtran to preserve DCT
coefficients. This is essential for rotating stego images.
For PNGs or when jpegtran is unavailable, uses PIL (which re-encodes
but PNGs are lossless anyway).
"""
img = Image.open(io.BytesIO(image_data))
original_format = img.format or "PNG"
img.close()
# Use jpegtran for lossless JPEG rotation
if lossless and original_format == "JPEG" and _jpegtran_available():
return _jpegtran_rotate(image_data, rotation)
# Fallback to PIL for PNGs or when jpegtran unavailable
img = Image.open(io.BytesIO(image_data))
# PIL rotation is counter-clockwise, we want clockwise
# 90 CW = 270 CCW, 180 = 180, 270 CW = 90 CCW
pil_rotation = {90: 270, 180: 180, 270: 90}[rotation]
rotated = img.rotate(pil_rotation, expand=True)
output = io.BytesIO()
# Save in original format if possible, fallback to PNG
save_format = original_format if original_format in ("JPEG", "PNG") else "PNG"
if save_format == "JPEG":
rotated.save(output, format="JPEG", quality=95)
else:
rotated.save(output, format="PNG")
output.seek(0)
return output.getvalue()
def _quick_validate_dct_header(image_data: bytes, seed: bytes) -> bool:
"""
Quick validation that only extracts enough DCT data to check magic bytes.
Returns True if header looks valid, False otherwise.
This is much faster than full extraction - only processes first ~8 blocks.
"""
try:
# Convert to grayscale for quick check
gray = _to_grayscale(image_data)
height, width = gray.shape
padded, _ = _pad_to_blocks(gray)
padded_h, padded_w = padded.shape
blocks_x = padded_w // BLOCK_SIZE
num_blocks = (padded_h // BLOCK_SIZE) * blocks_x
# Generate block order
block_order = _generate_block_order(num_blocks, seed)
# Only extract first 8 blocks (enough for RS length prefix + header)
# 8 blocks * 16 bits/block = 128 bits = 16 bytes (covers RS prefix)
blocks_needed = min(8, len(block_order))
all_bits = []
for block_num in block_order[:blocks_needed]:
by = (block_num // blocks_x) * BLOCK_SIZE
bx = (block_num % blocks_x) * BLOCK_SIZE
block = padded[by : by + BLOCK_SIZE, bx : bx + BLOCK_SIZE].astype(np.float32)
dct_block = dctn(block, norm="ortho")
for row, col in EMBED_POSITIONS:
coef = dct_block[row, col]
bit = _extract_bit_from_coeff(coef)
all_bits.append(bit)
# Check RS format first (3 copies of 8-byte length header)
if len(all_bits) >= RS_LENGTH_PREFIX_SIZE * 8:
length_prefix_bits = all_bits[: RS_LENGTH_PREFIX_SIZE * 8]
length_prefix_bytes = bytes(
[
sum(length_prefix_bits[i * 8 : (i + 1) * 8][j] << (7 - j) for j in range(8))
for i in range(RS_LENGTH_PREFIX_SIZE)
]
)
# Check if 2+ copies match (indicates valid RS format)
copies = []
for i in range(RS_LENGTH_COPIES):
start = i * RS_LENGTH_HEADER_SIZE
end = start + RS_LENGTH_HEADER_SIZE
copies.append(length_prefix_bytes[start:end])
from collections import Counter
counter = Counter(copies)
_, count = counter.most_common(1)[0]
if count >= 2:
return True # Looks like valid RS format
# Check legacy format (magic bytes in first 10 bytes)
if len(all_bits) >= HEADER_SIZE * 8:
try:
_parse_header(all_bits[: HEADER_SIZE * 8])
return True # Magic bytes matched
except (ValueError, InvalidMagicBytesError):
pass
return False
except Exception:
return False
def extract_from_dct( def extract_from_dct(
stego_image: bytes, stego_image: bytes,
seed: bytes, seed: bytes,
progress_file: str | None = None, progress_file: str | None = None,
) -> bytes: ) -> bytes:
"""Extract data from DCT stego image.""" """
img = Image.open(io.BytesIO(stego_image)) Extract data from DCT stego image.
If extraction fails with InvalidMagicBytesError, automatically tries
90°, 180°, and 270° rotations to handle images that were rotated after
encoding (e.g., by external tools or EXIF orientation changes).
Uses quick header validation to skip obviously invalid rotations.
"""
rotations_to_try = [0, 90, 180, 270]
last_error = None
valid_rotations = []
# Phase 1: Quick validation to find candidate rotations
for rotation in rotations_to_try:
if rotation == 0:
image_to_check = stego_image
else:
image_to_check = _rotate_image_bytes(stego_image, rotation)
if _quick_validate_dct_header(image_to_check, seed):
valid_rotations.append((rotation, image_to_check))
# If no rotations pass quick check, try all anyway (fallback)
if not valid_rotations:
# Must try all rotations - quick validation might have failed due to
# scipy vs jpegio differences or other edge cases
for rotation in rotations_to_try:
if rotation == 0:
valid_rotations.append((0, stego_image))
else:
valid_rotations.append((rotation, _rotate_image_bytes(stego_image, rotation)))
# Phase 2: Full extraction on valid candidates
for rotation, image_to_decode in valid_rotations:
try:
img = Image.open(io.BytesIO(image_to_decode))
fmt = img.format fmt = img.format
img.close() img.close()
if fmt == "JPEG" and HAS_JPEGIO: if fmt == "JPEG" and HAS_JPEGIO:
try: try:
return _extract_jpegio(stego_image, seed, progress_file) result = _extract_jpegio(image_to_decode, seed, progress_file)
except ValueError: if rotation != 0:
pass try:
from . import debug
debug.print(f"DCT decode succeeded after {rotation}° rotation")
except Exception:
pass # Don't let debug logging break extraction
return result
except (ValueError, InvalidMagicBytesError) as e:
last_error = e if isinstance(e, InvalidMagicBytesError) else last_error
continue
_check_scipy() _check_scipy()
return _extract_scipy_dct_safe(stego_image, seed, progress_file) result = _extract_scipy_dct_safe(image_to_decode, seed, progress_file)
if rotation != 0:
try:
from . import debug
debug.print(f"DCT decode succeeded after {rotation}° rotation")
except Exception:
pass # Don't let debug logging break extraction
return result
except InvalidMagicBytesError as e:
last_error = e
continue
# All rotations failed
raise last_error or InvalidMagicBytesError("Not a Stegasoo image (tried all rotations)")
def _extract_scipy_dct_safe( def _extract_scipy_dct_safe(